From 43cd7a2907eb015e539fb48c41c4528d51b62069 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 14:30:55 -0300 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] chore: bump to 0.9.34; changelog and docs for #1682 (#2/#3/#4/#5/#6/#8/#11/#12/#13/#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the PHP receiver-typed member-call work as 0.9.34. The changelog entry names what now resolves (typed properties and constructor-promoted params via `$this->prop`, nullsafe receivers, typed params, `$var = new T()` locals, and inline `(new T())->m()` as the one EXTRACTED form, gated on declared-namespace corroboration) and, at equal length, what is deliberately refused — untyped, union- and intersection-typed receivers, interface/enum/trait-typed receivers including across incremental rebuilds, corpus-duplicate short names, methods the receiver's class does not declare (so `__call` fabricates nothing), chained and array-element receivers, locals rebound or rebound by `global`/`static`, closure /arrow/foreach/destructuring shadowing, anonymous classes, and `self`/`static`/`parent`. Three behaviour deltas are called out because consumers weigh edges by confidence: a same-file call through a TYPED receiver moves EXTRACTED -> INFERRED 0.8 (measured on the base commit vs head, `/tmp/probe7_changelog_claims.py`); a qualified inline `new` is EXTRACTED while the same name written as a local stays INFERRED; and the language-scoped receiver index is two fixes, not one — polyglot corpora stop leaking cross-language edges AND regain PHP/ObjC edges a foreign same-short-named class used to suppress. Recall gaps (traits, inherited methods, enum methods as targets, typed params in top-level functions) and the use-alias-outside-corpus false-positive risk are named, as are the three items still open against this work. The docs confidence section gains a note that the member-call resolvers are a deterministic 0.8 INFERRED source distinct from the LLM rubric, plus the PHP refusal policy. The version bump rolls the version-namespaced AST cache. Verified end to end on the live repro corpus: pre-feature code fills `cache/ast/v0.9.33/`; head code at 0.9.33 serves those stale entries and produces NO receiver-aware edges even with all five files re-dispatched; at 0.9.34 the namespace misses, the corpus is re-parsed, and all three expected edges appear — `leadcontroller_index -> leadhunterservice_search` INFERRED 0.8, `paymentcontroller_store -> mixedpaymentservice_resolve` EXTRACTED 1.0, and the static control `paymentcontroller_store -> sucursalcontext` INFERRED 0.8 unchanged (`/tmp/probe7_bump_control.py`, `/tmp/probe7_cache_boundary.py`). The bump does not by itself force a re-extraction — an unchanged stat index short-circuits before the AST cache is consulted — so the changelog tells users to run `graphify update .` or drop `manifest.json`. The AST shapes the resolution reads are probed across every tree-sitter-php version pyproject accepts (0.23.0 through 0.24.1, twelve releases): 80/80 shape assertions hold on each, including the anonymous-class and `self`/`static` in type position cases, so the floor stays at >=0.23 (`/tmp/probe7_php_grammar.py`, `/tmp/probe7_php_versions.sh`). The 85 PHP tests also pass under 0.23.0, 0.23.5 and 0.23.11. uv.lock carries the one line that has to change: uv 0.12.1 rewrites 106 marker lines on a full `uv lock`, so the graphifyy version line is edited on its own. `uv lock --check` passes afterwards, which it did not before (the lock had been left at 0.9.31 across the 0.9.32 and 0.9.33 bumps). Suite: 4024 passed, 36 skipped — unchanged from the pre-release baseline. --- CHANGELOG.md | 10 ++++++++++ docs/how-it-works.md | 4 ++++ pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 148fd60e58..4c40030a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.34 (unreleased) + +- Feature: PHP instance-method calls on a typed receiver now resolve to the method they really reach, instead of a bare same-name match (#1682). `$this->prop->method()` binds to the type declared on the property — including a constructor-promoted param — and the same typing covers nullsafe receivers (`$obj?->method()`), natively typed parameters, and `$var = new T()` locals. `(new Service())->method()` resolves too and is the one form tagged EXTRACTED (1.0), since the class is named right there in the source; the promotion applies only when the written namespace corroborates the class node found for it, compared against the namespace the defining file DECLARES rather than against its PSR-4 path (a file at `app/Services/Client.php` may well declare `namespace App\Vendor;`). Every other typed receiver is INFERRED (0.8). The motivating case is a Laravel corpus: `$this->leadHunter->search(...)` used to reach nothing, and now reaches `App\Services\LeadHunterService::search`. +- Refuse-don't-guess is the policy wherever the receiver's type is not provably one concrete in-corpus class, so all of these deliberately emit NO edge: untyped, union-typed (`A|B`) and intersection-typed (`A&B`) receivers; receivers typed by an `interface`, `enum` or `trait` — none of which mints a definition node, so binding one would pick a same-short-named stranger (the `App\Contracts\Notifier` vs `App\Support\Notifier` collision, or `App\Enums\Status` beside an Eloquent `App\Models\Status`), and that refusal now survives an incremental rebuild by persisting those names on the declaring file's node; a short type name that does not match exactly one class in the corpus; a method the receiver's own class does not declare, so `__call` magic dispatch fabricates nothing; chained (`$this->factory()->method()`) and array-element (`$bag['k']->method()`) receivers; a local rebound to anything but a matching `new`, or rebound to other storage by `global`/`static`; a name shadowed by a closure or arrow-function parameter, a `foreach` target, or list destructuring; anonymous classes (`new class { ... }`); and `self`/`static`/`parent` in type position, which need inheritance context the raw-call facts do not carry. +- Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. +- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. +- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. +- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). +- The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. + ## 0.9.33 (unreleased) - Fix: the C# `partial class` merge (#2332) no longer conflates two same-named classes that live in different assemblies (#2411, thanks @JensD-git). The merge now keys on assembly (nearest ancestor directory containing a `.csproj`/`.fsproj`/`.vbproj`) in addition to namespace and name, so genuine partial halves within one project still merge while same-name types in separate projects stay distinct. A corpus with no project file keeps merging by namespace and name as before. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index e0e6e5275d..3d78c1f9b0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -48,6 +48,10 @@ EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric - **0.65** — weak (naming similarity only) - **0.55** — speculative +That rubric describes edges Claude inferred. The per-language member-call resolvers are a separate, deterministic source of INFERRED edges: they read the receiver's declared type out of the AST and bind at a fixed **0.8**, reserving EXTRACTED (1.0) for a receiver whose type is named in the source at the call site. + +**PHP member calls refuse rather than guess.** `$this->prop->method()`, `$obj?->method()`, a typed parameter and a `$var = new T()` local all bind to the receiver's declared type as INFERRED 0.8; `(new Service())->method()` is EXTRACTED 1.0, but only when the namespace written at the call site corroborates the class that was found. When the type is not provably one concrete in-corpus class, no edge is emitted at all — untyped, union- and intersection-typed receivers, receivers typed by an `interface`, `enum` or `trait`, a short name that matches two classes, a method the receiver's class does not declare, chained and array-element receivers, a local rebound or shadowed anywhere in the method, anonymous classes, and `self`/`static`/`parent`. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one. + --- ## Token benchmark diff --git a/pyproject.toml b/pyproject.toml index 5168ebd3bb..7b8e71551c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.33" +version = "0.9.34" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 8573a9e9d6..c737c43bf0 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.31" +version = "0.9.34" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From cf49aff0fd46454c1c58ef86b1921e657f949bdc Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:56:00 -0300 Subject: [PATCH 13/41] fix(php): emit indirect_call for first-class callables (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.1 `$obj->method(...)` creates a Closure — it names the method without invoking it — but the 8.1 grammar reuses `member_call_expression` for it, so the shared `node.type in config.call_types` gate saw an ordinary call and the PHP branch never inspected the `arguments` field. The edge landed as `calls`, claiming control flow transfers at that line. Maintainer decision on #15 (option 2): re-tag as `indirect_call`, the relation this repo already uses for "named but not invoked". No sibling resolver emits `calls` for the equivalent syntax — C# method groups, Java method references and TS bare member references are never captured at all — so PHP was the outlier, and deleting the edge would lose a real dependency that suppression cannot express. Detection is stamped at capture as `fcc` on the raw-call fact, keyed on the argument list being exactly the `...` placeholder: probe-verified on the pinned tree-sitter-php 0.24.1, `m(...)` parses as `arguments: (arguments (variadic_placeholder))` — one named child of that type — while `m()`, `m(1)` and the spread `m(...$args)` do not. `_resolve_php_member_calls` reads the marker and flips only the relation: receiver typing, the single-definition and interface/enum/trait refusals, and the confidence ladder are unchanged. The in-file path (`$this->m(...)` binding to a method in the same file) re-tags too, at unchanged EXTRACTED confidence. Ordinary invocations keep `calls`, and a caller that both invokes and references the same method keeps the `calls` edge regardless of source order — the fcc dedup uses its own pair set, and the cross-file pass sorts direct calls ahead of references. Static (`Helper::fmt(...)`) and plain-function (`strlen(...)`) first-class callables are out of scope: neither resolves to a method target today. Tests: 7 new cases through the public extract() seam — plain, nullsafe and `$this` forms (each with a same-named decoy asserted to get no edge), plus regression guards for the ordinary member call, the ordinary `$this` call, the `...$args` spread, and direct-call precedence. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- docs/how-it-works.md | 2 + graphify/extract.py | 125 +++++++------ graphify/extractors/engine.py | 59 +++++- tests/test_php_first_class_callable.py | 237 +++++++++++++++++++++++++ 5 files changed, 370 insertions(+), 56 deletions(-) create mode 100644 tests/test_php_first_class_callable.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c40030a5e..73b22e53c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. -- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). +- Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. +- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. ## 0.9.33 (unreleased) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3d78c1f9b0..3fa25756b3 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -52,6 +52,8 @@ That rubric describes edges Claude inferred. The per-language member-call resolv **PHP member calls refuse rather than guess.** `$this->prop->method()`, `$obj?->method()`, a typed parameter and a `$var = new T()` local all bind to the receiver's declared type as INFERRED 0.8; `(new Service())->method()` is EXTRACTED 1.0, but only when the namespace written at the call site corroborates the class that was found. When the type is not provably one concrete in-corpus class, no edge is emitted at all — untyped, union- and intersection-typed receivers, receivers typed by an `interface`, `enum` or `trait`, a short name that matches two classes, a method the receiver's class does not declare, chained and array-element receivers, a local rebound or shadowed anywhere in the method, anonymous classes, and `self`/`static`/`parent`. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one. +A PHP 8.1 first-class callable — `$obj->method(...)`, including the nullsafe and `$this` forms — resolves by exactly those rules but is emitted as **`indirect_call`**, not `calls`: the syntax creates a `Closure` and names the method without invoking it, the same shape the resolver already labels `indirect_call` for a callback passed by name. `calls` therefore keeps meaning "control flow transfers here". The spread form `$obj->method(...$args)` is a real invocation and stays `calls`. + --- ## Token benchmark diff --git a/graphify/extract.py b/graphify/extract.py index 447ae4021c..7111a8d78a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3149,6 +3149,11 @@ def _resolve_php_member_calls( ambiguous receiver type is skipped rather than falling back to a bare method-name match — a Laravel corpus has many identically named service methods, and guessing between them is worse than an absent edge. + + A raw call marked ``fcc`` is a PHP 8.1 first-class callable + (``$obj->method(...)``): the method is referenced, not invoked, so it + resolves by exactly the rules above but is emitted as ``indirect_call`` + (#15). """ def key(label: str) -> str: # PHP class and method names are case-insensitive. @@ -3229,63 +3234,75 @@ def declared_fqn(type_node: dict | None) -> str | None: return by_name.get(key(type_node.get("label", ""))) existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} - for result in per_file: - for raw_call in result.get("raw_calls", []): - if raw_call.get("lang") != "php" or not raw_call.get("is_member_call"): + # First-class callables (`fcc`, #15) go last: the sort is stable, so ordinary + # invocations keep source order and claim the (caller, method) pair first. A + # real call outranks a mere reference to the same method, which is also the + # precedence the in-file and cross-file indirect-dispatch passes use. + php_raw_calls = [ + raw_call + for result in per_file + for raw_call in result.get("raw_calls", []) + if raw_call.get("lang") == "php" and raw_call.get("is_member_call") + ] + php_raw_calls.sort(key=lambda raw_call: bool(raw_call.get("fcc"))) + for raw_call in php_raw_calls: + receiver = raw_call.get("receiver") + callee = raw_call.get("callee") + caller = raw_call.get("caller_nid") + if not receiver or not callee or not caller: + continue + + if receiver == "this": + exact = True + type_nid = enclosing_type.get(caller) + if not type_nid: continue - receiver = raw_call.get("receiver") - callee = raw_call.get("callee") - caller = raw_call.get("caller_nid") - if not receiver or not callee or not caller: + else: + exact = False + type_name = raw_call.get("receiver_type") + if not type_name: + continue # untyped / union-typed / unknown receiver: refuse + if key(type_name) in non_class_type_names: + # An interface names no implementation, a trait is not a + # type, and an enum's methods live on no definition node: + # refuse rather than bind a same-short-named stranger. continue + type_defs = type_def_nids.get(key(type_name), []) + if len(type_defs) != 1: + continue # short name collides across the corpus: refuse + type_nid = type_defs[0] + if receiver == "(new)": + # The class is named in the source; promote to EXTRACTED + # only when the written namespace backs the node we found. + type_node = node_by_id.get(type_nid) + exact = _php_qualified_corroborates( + raw_call.get("receiver_qualified"), + type_node, + declared_fqn(type_node), + ) - if receiver == "this": - exact = True - type_nid = enclosing_type.get(caller) - if not type_nid: - continue - else: - exact = False - type_name = raw_call.get("receiver_type") - if not type_name: - continue # untyped / union-typed / unknown receiver: refuse - if key(type_name) in non_class_type_names: - # An interface names no implementation, a trait is not a - # type, and an enum's methods live on no definition node: - # refuse rather than bind a same-short-named stranger. - continue - type_defs = type_def_nids.get(key(type_name), []) - if len(type_defs) != 1: - continue # short name collides across the corpus: refuse - type_nid = type_defs[0] - if receiver == "(new)": - # The class is named in the source; promote to EXTRACTED - # only when the written namespace backs the node we found. - type_node = node_by_id.get(type_nid) - exact = _php_qualified_corroborates( - raw_call.get("receiver_qualified"), - type_node, - declared_fqn(type_node), - ) - - method_nids = method_index.get((type_nid, key(callee)), set()) - if len(method_nids) != 1: - continue # the typed receiver's class has no such method: refuse - method_nid = next(iter(method_nids)) - if method_nid == caller or (caller, method_nid) in existing_pairs: - continue - existing_pairs.add((caller, method_nid)) - all_edges.append({ - "source": caller, - "target": method_nid, - "relation": "calls", - "context": "call", - "confidence": "EXTRACTED" if exact else "INFERRED", - "confidence_score": 1.0 if exact else 0.8, - "source_file": raw_call.get("source_file", ""), - "source_location": raw_call.get("source_location"), - "weight": 1.0, - }) + method_nids = method_index.get((type_nid, key(callee)), set()) + if len(method_nids) != 1: + continue # the typed receiver's class has no such method: refuse + method_nid = next(iter(method_nids)) + if method_nid == caller or (caller, method_nid) in existing_pairs: + continue + existing_pairs.add((caller, method_nid)) + all_edges.append({ + "source": caller, + "target": method_nid, + # A first-class callable NAMES the method without invoking it, so it + # is the repo's `indirect_call`, not `calls` (#15). Everything else + # above — receiver typing, the single-definition and interface/enum/ + # trait refusals, the confidence ladder — is deliberately shared. + "relation": "indirect_call" if raw_call.get("fcc") else "calls", + "context": "call", + "confidence": "EXTRACTED" if exact else "INFERRED", + "confidence_score": 1.0 if exact else 0.8, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + }) def _resolve_objc_member_calls( diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index bd7bbc9349..98884c1b1c 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4283,6 +4283,10 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: seen_call_pairs: set[tuple[str, str]] = set() seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup + # PHP first-class-callable indirect_call dedup (#15), kept apart from + # seen_call_pairs so a `$this->m(...)` reference can never swallow the real + # `$this->m()` call to the same target. + seen_php_fcc_pairs: set[tuple[str, str]] = set() seen_dyn_import_pairs: set[tuple[str, str]] = set() seen_static_ref_pairs: set[tuple[str, str, str]] = set() seen_helper_ref_pairs: set[tuple[str, str, str]] = set() @@ -4513,6 +4517,10 @@ def walk_calls( # short name (for lookup) and the written text (for corroboration). php_inline_new_type: str | None = None php_inline_new_qualified: str | None = None + # PHP 8.1 first-class callable `$obj->method(...)` (#15): a + # reference to the method, not an invocation — re-tagged as + # `indirect_call` below and by the cross-file PHP resolver. + php_fcc: bool = False # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -4683,6 +4691,21 @@ def walk_calls( member_receiver = "(new)" php_inline_new_type = short php_inline_new_qualified = _read_text(cls, source) + # First-class callable `$obj->method(...)` (#15). PHP 8.1 + # reuses member_call_expression for it, so the shared + # `node.type in config.call_types` gate cannot tell it + # from an invocation — the argument list can. Probe- + # verified on the pinned grammar (tree-sitter-php 0.24.1): + # `m(...)` parses as `arguments: (arguments + # (variadic_placeholder))` — exactly one named child of + # that type. `m()`, `m(1)` and the spread `m(...$args)` + # (one `argument` child) do not, so the discriminator is + # unambiguous. + args_node = node.child_by_field_name("arguments") + if args_node is not None: + named_args = args_node.named_children + php_fcc = (len(named_args) == 1 + and named_args[0].type == "variadic_placeholder") elif config.ts_module == "tree_sitter_cpp": # C++: function field, then field_expression/qualified_identifier func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None @@ -4843,9 +4866,38 @@ def walk_calls( tgt_nid = label_to_nid.get(callee_name) if tgt_nid and tgt_nid != caller_nid: pair = (caller_nid, tgt_nid) - if pair not in seen_call_pairs: + if php_fcc: + # `$this->m(...)` names an in-file method: same target, + # same EXTRACTED confidence, distinct relation (#15). A + # direct call to the target already recorded wins the + # pair — the precedence _emit_indirect_ref already uses. + if (pair not in seen_call_pairs + and pair not in seen_php_fcc_pairs): + seen_php_fcc_pairs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "indirect_call", + "context": "call", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + elif pair not in seen_call_pairs: seen_call_pairs.add(pair) line = node.start_point[0] + 1 + if pair in seen_php_fcc_pairs: + # An earlier first-class-callable reference claimed + # this pair; the real invocation supersedes it, so + # the outcome does not depend on source order. + seen_php_fcc_pairs.discard(pair) + edges[:] = [ + e for e in edges + if not (e.get("relation") == "indirect_call" + and e.get("source") == caller_nid + and e.get("target") == tgt_nid) + ] edges.append({ "source": caller_nid, "target": tgt_nid, @@ -4898,6 +4950,11 @@ def walk_calls( # stamp the receiver type resolved above (#1682). if config.ts_module == "tree_sitter_php": rc_entry["lang"] = "php" + if php_fcc: + # Marker read by _resolve_php_member_calls, which + # emits `indirect_call` for it under the SAME + # target-resolution and refusal rules (#15). + rc_entry["fcc"] = True if _php_receiver_type: rc_entry["receiver_type"] = _php_receiver_type if php_inline_new_qualified: diff --git a/tests/test_php_first_class_callable.py b/tests/test_php_first_class_callable.py new file mode 100644 index 0000000000..41cffe8545 --- /dev/null +++ b/tests/test_php_first_class_callable.py @@ -0,0 +1,237 @@ +"""PHP 8.1 first-class callables emit ``indirect_call``, not ``calls`` (#15). + +``$obj->method(...)`` creates a ``Closure`` — the method is *named*, not invoked, +so control flow does not transfer at that line. The repo already models +"named but not invoked" as the distinct ``indirect_call`` relation, and PHP only +leaked into ``calls`` because the 8.1 grammar reuses ``member_call_expression`` +for first-class-callable syntax. + +Discriminator (probe-verified on the pinned tree-sitter-php 0.24.1): the +``arguments`` node of ``m(...)`` has exactly one named child, of type +``variadic_placeholder``. ``m()``, ``m(1)`` and ``m(...$args)`` do not. + +Target resolution and the refuse-don't-guess rules are UNCHANGED — only the +relation moves. Every positive test carries a decoy class with an identically +named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _edges(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt, rel): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + edges = { + (edge["source"], edge["target"], edge.get("relation")): edge + for edge in result["edges"] + if edge.get("relation") in ("calls", "indirect_call") + } + return edges, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's declared type can. +_SERVICE = " str: + return ( + "leadHunter->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + # Target resolution is unchanged: same receiver typing, same confidence as + # the ordinary `$this->leadHunter->search([])` call would get. + edge = edges[(index, service_search, "indirect_call")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_nullsafe_first_class_callable_emits_indirect_call(tmp_path: Path): + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return $this->leadHunter?->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + + +def test_first_class_callable_on_this_emits_indirect_call(tmp_path: Path): + """`$this->helper(...)` resolves in-file, so it must re-tag on that path too.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "normalise(...);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + # Decoy: a same-named method in another class must not pick up the edge. + "app/Audit/Normaliser.php": ( + "leadHunter->search(['status' => 'open']);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + edge = edges[(index, service_search, "calls")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + assert edge["context"] == "call" + + +def test_ordinary_this_call_still_emits_calls(tmp_path: Path): + """Regression guard for the in-file path: `$this->normalise()` stays EXTRACTED `calls`.""" + edges, r = _edges(tmp_path, { + "app/Http/Controllers/LeadController.php": ( + "normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + own = _find(r, ".normalise()", "leadcontroller") + + assert (index, own, "calls") in edges + assert (index, own, "indirect_call") not in edges + assert edges[(index, own, "calls")]["confidence"] == "EXTRACTED" + + +def test_spread_argument_is_not_a_first_class_callable(tmp_path: Path): + """`search(...$args)` IS an invocation — only a bare `...` placeholder re-tags.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$args = []; return $this->leadHunter->search(...$args);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + +def test_direct_call_wins_over_first_class_callable_to_the_same_method(tmp_path: Path): + """Both forms in one caller: the real invocation keeps the pair (existing + indirect-dispatch precedence), regardless of which appears first.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$f = $this->leadHunter->search(...);\n" + " return $this->leadHunter->search([]);" + ), + "app/Http/Controllers/TeamController.php": ( + "normalise(...);\n" + " return $this->normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + team_index = _find(r, ".index()", "teamcontroller") + own = _find(r, ".normalise()", "teamcontroller") + assert (team_index, own, "calls") in edges + assert (team_index, own, "indirect_call") not in edges From f310fb541f7d021d0f594cd27bab5cf766c4bf87 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:57:36 -0300 Subject: [PATCH 14/41] fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User story 11 promised no `calls` edge for a union- or intersection-typed receiver. The cross-file resolver honoured it, but the extractor's legacy in-file bare-name arm did not: `_php_defer` was derived from whether a `receiver_type` had been STAMPED, so an annotation REFUSED by the concrete-type policy looked exactly like no annotation at all. A one-file `private Alpha|Beta $svc; $this->svc->run();` therefore bound to whichever `run()` the file's label index saw last — file order — at EXTRACTED confidence. Pre-existing, not a branch regression: it reproduces at the merge-base 4e7e6b1. The receiver table now distinguishes three states for a key: a concrete type (resolve it), PRESENT-but-None (annotation refused as multi-class, defer), and ABSENT (no annotation, keep today's in-file match). Precedence is concrete > refusal > absent, so a union-typed param later assigned a `new T()` still resolves to T while a poisoned one stays refused. Deletion scope is deliberately narrow, since deferring removes edges that exist today: only union (`A|B`) and intersection (`A&B`) annotations defer — including `A|null`, which is semantically `?A` but parses as a union node. The concrete-type policy's other refusals declare no multiplicity and keep their in-file edge: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched, preserving #2's accepted deviation and user story 9. Named in the CHANGELOG. Tests (all through the `extract()` seam): same-file union and intersection variants for properties, params and a promoted param — the separate-file negatives at tests/test_php_member_calls.py:211 spread `**_CORPUS`, which puts the decoys in other files, so the in-file arm never ran and they passed for the wrong reason; no intersection test existed at all. Plus regression guards that untyped properties/params, `$this->method()` and a `self`-typed property keep their same-file edges, locking the deletion scope. 5 red before the fix, 9 green after. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- graphify/extractors/engine.py | 104 +++++++++++++++---- tests/test_php_member_calls.py | 184 +++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c40030a5e..c3e8187cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. -- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). +- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); 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) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index bd7bbc9349..d38a44a7a0 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -740,6 +740,29 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: return None +# Type expressions that declare MORE THAN ONE possible class for a receiver: +# union (`A|B`) and intersection (`A&B`) — node names probe-verified against +# tree-sitter-php 0.24.1. +_PHP_MULTI_TYPE_NODES = frozenset({"union_type", "intersection_type"}) + + +def _php_multi_typed_annotation(type_node) -> bool: + """True when a PHP type annotation names more than one candidate class (#9). + + `_php_concrete_type_name` refuses several shapes, and the two reasons for + refusal must be told apart at the call site (user story 11): a receiver whose + annotation is a UNION or INTERSECTION provably has several possible classes, + so an in-file bare-name match can only pick one of them by file order and + must be suppressed. The policy's other refusals — `self`/`static`/`parent`, + primitives, `mixed`/`object`/`iterable`/`callable`, a nullable wrapping more + than one type — declare no such multiplicity, so they deliberately keep + today's in-file edge, exactly like a genuinely untyped receiver (#2's + accepted deviation, user story 9). `self` especially: it names the calling + class, whose methods usually ARE the in-file match. + """ + return type_node is not None and type_node.type in _PHP_MULTI_TYPE_NODES + + # Subtrees that are a DIFFERENT binding scope than the method being scanned: # their assignments must not type the enclosing method's variables. Closures are # deliberately absent — their calls are attributed to the enclosing method, so @@ -761,8 +784,8 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: def _php_method_receiver_types( method_node, source: bytes, - field_types: dict[str, str], -) -> dict[str, str]: + field_types: dict[str, str | None], +) -> dict[str, str | None]: """Build the receiver type table visible to one PHP method (#1682). ``this.`` keys come from the declaring class's typed properties and @@ -777,9 +800,18 @@ def _php_method_receiver_types( target, a list-destructuring element, or a `global`/`static` statement rebinding it to other storage. Poisoning is order-independent, which is why it can be decided from a single unordered walk. + + A key mapped to None is PRESENT but unresolved: its annotation named several + candidate classes (`A|B`, `A&B`), which the call site must tell apart from an + ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > + multi-class refusal > absent, so a union-typed param later assigned a `new T()` + still resolves to T, while a poisoned union-typed one stays refused. """ - table = {f"this.{name}": type_name for name, type_name in field_types.items()} + table: dict[str, str | None] = { + f"this.{name}": type_name for name, type_name in field_types.items() + } method_types: dict[str, str] = {} + multi_typed_params: set[str] = set() ambiguous: set[str] = set() def poison(name: str) -> None: @@ -832,13 +864,17 @@ def new_type_name(node) -> str | None: for param in params.children: if param.type not in ("simple_parameter", "property_promotion_parameter"): continue - type_name = _php_concrete_type_name( - param.child_by_field_name("type"), source - ) + type_node = param.child_by_field_name("type") + type_name = _php_concrete_type_name(type_node, source) name_node = param.child_by_field_name("name") if name_node is not None and type_name: - # Untyped / union / primitive params simply stay unbound. + # Untyped / primitive params simply stay unbound. bind(_read_text(name_node, source).lstrip("$"), type_name) + elif name_node is not None and _php_multi_typed_annotation(type_node): + # A union/intersection param is not BOUND — `bind(None)` would + # poison it, and poisoning is indistinguishable from untyped. + # Mark it instead, and let the merge below apply precedence. + multi_typed_params.add(_read_text(name_node, source).lstrip("$")) body = method_node.child_by_field_name("body") stack = list(body.children) if body is not None else [] @@ -884,6 +920,10 @@ def new_type_name(node) -> str | None: table.update(method_types) for name in ambiguous: table.pop(name, None) + for name in multi_typed_params: + # setdefault, so a concrete type learned from a `new` wins; a poisoned + # name (popped just above) falls back to the multi-class refusal. + table.setdefault(name, None) return table @@ -2656,7 +2696,10 @@ def _extract_generic( csharp_method_scopes: dict[int, tuple[object, str]] = {} # PHP receiver typing (#1682): typed properties and constructor-promoted # params of the declaring class, keyed `this.` per method scope. - php_field_types: dict[str, dict[str, str]] = {} + # `prop -> declared type` per class. A value of None means the annotation was + # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call + # site must tell apart from an ABSENT key = no annotation at all (#9). + php_field_types: dict[str, dict[str, str | None]] = {} php_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() @@ -3500,8 +3543,12 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # #1682: remember the property's declared type so a later # `$this->prop->method()` resolves against it. Only a single # concrete class name counts — unions/primitives are refused. + # A multi-class annotation is recorded as None so the call site + # can defer rather than bare-name match it (#9); every other + # refusal leaves the property out of the table entirely. type_name = _php_concrete_type_name(c, source) - if type_name: + multi_typed = _php_multi_typed_annotation(c) + if type_name or multi_typed: fields = php_field_types.setdefault(parent_class_nid, {}) for pe in node.children: if pe.type != "property_element": @@ -3841,11 +3888,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = sub break # #1682: a promoted param IS a typed class property — - # record it in the same `this.` receiver table. + # record it in the same `this.` receiver table, + # multi-class annotations included as None (#9). if is_promoted and parent_class_nid: promoted_type = _php_concrete_type_name(type_node, source) v = p.child_by_field_name("name") - if promoted_type and v is not None: + if v is not None and ( + promoted_type or _php_multi_typed_annotation(type_node) + ): php_field_types.setdefault(parent_class_nid, {})[ _read_text(v, source).lstrip("$") ] = promoted_type @@ -4465,7 +4515,8 @@ def _php_class_const_scope(n) -> str | None: def walk_calls( node, caller_nid: str, - receiver_types: dict[str, str] | None = None, + # PHP entries may map a key to None — see _php_method_receiver_types. + receiver_types: dict[str, str | None] | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -4812,18 +4863,33 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - # PHP (#1682): defer ONLY when the receiver's type is actually - # known — a typed `$this->prop->m()` must not bare-match an - # unrelated same-named method in this file. Plain `$this->m()` - # and untyped receivers keep today's in-file match, since the - # resolver could add nothing for them anyway. + # PHP (#1682): defer when the receiver's type is actually known — + # a typed `$this->prop->m()` must not bare-match an unrelated + # same-named method in this file. Plain `$this->m()` and untyped + # receivers keep today's in-file match, since the resolver could + # add nothing for them anyway. + # + # ALSO defer when the annotation was PRESENT but named several + # candidate classes (`A|B`, `A&B`) — user story 11 (#9). Such a + # receiver stamps no type, so before this the refusal was + # indistinguishable from "untyped" and the in-file arm bound the + # call to whichever same-named method came last in the file, at + # EXTRACTED confidence. The receiver table encodes the difference: + # a PRESENT key mapped to None is a refused multi-class + # annotation, an ABSENT key is no annotation at all. _php_receiver_type: str | None = None + _php_multi_typed_receiver = False if config.ts_module == "tree_sitter_php": if php_inline_new_type: _php_receiver_type = php_inline_new_type elif member_receiver and member_receiver != "this": - _php_receiver_type = (receiver_types or {}).get(member_receiver) - _php_defer = bool(_php_receiver_type) + _php_types = receiver_types or {} + _php_receiver_type = _php_types.get(member_receiver) + _php_multi_typed_receiver = ( + _php_receiver_type is None + and member_receiver in _php_types + ) + _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver if _java_defer or _php_defer or ( is_member_call and member_receiver diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index a8ef926fc3..2eb355b259 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -1876,3 +1876,187 @@ def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): go = _find(inc, ".go()", "dispatcher") assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls assert not _notified(inc_calls, go) + + +# ── Same-file union / intersection receivers (user story 11, #9) ────────────── +# +# The separate-file negatives above pass for the wrong reason: with the decoys in +# other files, the extractor's LEGACY in-file bare-name arm never runs, so only +# the cross-file resolver is exercised. When the candidate classes live in the +# SAME file as the call, that arm fires and binds the receiver to whichever +# same-named method the label index saw last — pure file order, stamped +# EXTRACTED. A union or intersection annotation proves the receiver has MORE +# THAN ONE possible class, so the extractor must defer to the resolver (which +# refuses an unstamped receiver) instead. +# +# Deletion scope is deliberately limited to `A|B` / `A&B`. The concrete-type +# policy also refuses `self`/`static`/`parent`, primitives and +# `mixed`/`object`/`iterable`/`callable`, but none of those declares MULTIPLE +# candidate classes — `self` in particular makes the in-file match likely +# correct — so they keep today's edge, exactly like a genuinely untyped receiver. + +def _same_file(receiver_decl: str, *, second_class: str = "", call: str = "$this->svc") -> str: + """One PHP file: candidate class(es) plus a Ctrl whose property is `$svc`.""" + return ( + "run();\n" + " }\n" + "}\n" + ) + + +_BETA = "class Beta { public function run(): int { return 2; } }\n" + + +def _ran(calls, go: str) -> list[str]: + """Every ``calls`` target of ``go``. Each fixture below makes exactly one call + (``->run()``), so the whole target list doubles as the assertion.""" + return sorted(tgt for src, tgt in calls if src == go) + + +def test_same_file_union_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha|Beta $svc` with BOTH candidates in the call's own file.""" + calls, r = _calls(tmp_path, { + "app/U.php": _same_file("private Alpha|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [], \ + "a union-typed receiver has no single class — the in-file bare-name " \ + "match would bind it to one of them by file order" + + +def test_same_file_intersection_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha&Beta $svc`: an intersection is named by user story 11 too, and had + no test at all before this ticket.""" + calls, r = _calls(tmp_path, { + "app/I.php": _same_file("private Alpha&Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/UP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_intersection_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/IP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_promoted_param_emits_no_edge(tmp_path: Path): + """A promoted constructor param is a typed property, reached by the same + `this.` key — the refusal must travel that channel too.""" + calls, r = _calls(tmp_path, { + "app/UPP.php": ( + "svc->run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_untyped_property_keeps_its_edge(tmp_path: Path): + """Regression guard for #2's accepted deviation (user story 9): a receiver + with NO annotation keeps today's same-file bare-name edge. Only an + annotation that was PRESENT and refused as multi-typed defers.""" + calls, r = _calls(tmp_path, { + "app/N.php": _same_file("protected $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_untyped_param_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/NP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_this_call_keeps_its_edge(tmp_path: Path): + """Regression guard (user story 9): `$this->method()` never carries a + receiver type and must stay on the in-file arm.""" + calls, r = _calls(tmp_path, { + "app/T.php": ( + "run(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "ctrl")] + + +def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): + """Deletion-scope boundary: `self` is refused by the concrete-type policy but + declares no multiplicity, so it is NOT deferred and keeps today's edge.""" + calls, r = _calls(tmp_path, { + "app/S.php": _same_file("protected self $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] From fe25b51c49483db41dcd1393a8d7962f8b258e3d Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:59:23 -0300 Subject: [PATCH 15/41] fix(php): recognize PHP 8.2 DNF property and promoted-param types (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `private (A&B)|C $x;` parses as a `disjunctive_normal_form_type` node, which neither the property scanner nor the promoted-param scanner named among the type shapes they accept. A DNF-typed property was therefore skipped outright and invisible twice over: * it never reached the receiver type table, so it kept minting the same-file bare-name `calls` edge the previous commit removes — a DNF type is a union at top level, so it has no single receiver class either; * `_php_collect_type_refs` never walked it, so none of its classes got a `references` edge, unlike the plain union property beside it. Naming the node in both scanners fixes both halves at once — they read the same type node, one for the receiver table and one for the reference walk, so the two cannot be separated without a throwaway DNF-only scan. Split out from the union/intersection commit because the reference edges are a behavior addition beyond issue 9's letter. `_php_multi_typed_annotation` gains the node, so DNF refuses exactly like `A|B` does; the deletion scope stated in the previous commit widens by this one shape and the CHANGELOG says so. Test asserts both halves through the `extract()` seam: no `calls` edge, and `references` edges to the DNF's classes. Red before, green after. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/engine.py | 16 ++++++++++++---- tests/test_php_member_calls.py | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e8187cca..0666935fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. - Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); 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. diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d38a44a7a0..99131a67e1 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -741,9 +741,12 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: # Type expressions that declare MORE THAN ONE possible class for a receiver: -# union (`A|B`) and intersection (`A&B`) — node names probe-verified against +# union (`A|B`), intersection (`A&B`) and PHP 8.2 disjunctive-normal-form +# (`(A&B)|C`, a union at top level) — node names probe-verified against # tree-sitter-php 0.24.1. -_PHP_MULTI_TYPE_NODES = frozenset({"union_type", "intersection_type"}) +_PHP_MULTI_TYPE_NODES = frozenset({ + "union_type", "intersection_type", "disjunctive_normal_form_type", +}) def _php_multi_typed_annotation(type_node) -> bool: @@ -3537,7 +3540,11 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: and parent_class_nid): for c in node.children: if c.type not in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + # PHP 8.2 `(A&B)|C`, absent from this list until + # #9 and so invisible to both the receiver table + # and the type-reference walk below. + "disjunctive_normal_form_type"): continue line = node.start_point[0] + 1 # #1682: remember the property's declared type so a later @@ -3884,7 +3891,8 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = None for sub in p.children: if sub.type in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + "disjunctive_normal_form_type"): type_node = sub break # #1682: a promoted param IS a typed class property — diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 2eb355b259..a9c0ecdfaa 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -2060,3 +2060,25 @@ def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): go = _find(r, ".go()", "ctrl") assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_dnf_typed_property_emits_no_edge_and_references_its_types(tmp_path: Path): + """PHP 8.2 disjunctive normal form (`(A&B)|C`) parses as its own node type, + which the property scanner's type-node list did not name — so a DNF property + reached neither the receiver table (it kept minting the bare-name edge this + ticket removes) nor the type-reference walk (its classes went unreferenced). + It is a union at top level, so it refuses like one, and references like one.""" + calls, r = _calls(tmp_path, { + "app/D.php": _same_file("private (Alpha&Beta)|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + refs = { + (edge["source"], edge["target"]) + for edge in r["edges"] if edge.get("relation") == "references" + } + ctrl = _find(r, "Ctrl", "d_ctrl") + assert (ctrl, _find(r, "Alpha", "d_alpha")) in refs + assert (ctrl, _find(r, "Beta", "d_beta")) in refs From f8c58e68af97887e3d962ef6152b870bc1d46ecd Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 20:01:54 -0300 Subject: [PATCH 16/41] feat(php): capture use FQN/alias/kind metadata on imports edges (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP `imports` edges carried no metadata: `_import_php` kept only `raw.split("\\")[-1]`, so the FQN, the alias and the `function`/`const` kind of every `use` statement were discarded at capture time. That is the fact base #16's decisive-refusal resolver needs, and it already existed — correctly parsed, group-use and aliases included — but only as a function-local map inside `_resolve_php_type_references`. Extract that parser (`_record_use_clause` plus the `namespace_use_declaration` walk) into three shared helpers in resolution.py and have both consumers call them. `_import_php` is dispatched per `namespace_use_clause` and never sees the declaration, so `_php_use_clause_context` recovers the group-use prefix and the declaration-level `function`/`const` keyword from the parent node; the edge then carries `target_fqn`, `alias` and `use_kind`, mirroring `_import_csharp`. Strictly metadata-only. The edge target stays keyed on the imported short name, `_PHP_CONFIG.import_types` stays `namespace_use_clause` (so a trait `use` inside a class body — a `use_declaration` — is still not an import), and `_resolve_php_type_references` keeps its exact behavior: it passes `apply_declaration_kind=False` because it has only ever honored a clause-level `function`/`const` keyword, never the declaration-level one a group `use function A\{f, g};` carries. Verified beyond the suite by diffing full extract() output (metadata stripped) over a PHP corpus covering every `use` form plus the existing fixtures — byte-identical before and after. Tests go through the public extract() seam: plain, aliased, group and aliased-group use, leading-backslash normalization, and edge targets unchanged. The `use function` / `use const` exclusion is asserted where it is observable — a same-file `use Vendor\Sdk\Base;` control re-points its supertype reference onto the FQN stub while the `use function` / `use const` names must not, so they stay on the bare stub. Suite: 4032 passed / 36 skipped (baseline 4024 / 36 plus 8 new). --- graphify/extract.py | 45 +++++---- graphify/extractors/resolution.py | 144 +++++++++++++++++++++------- tests/test_php_use_imports.py | 152 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 51 deletions(-) create mode 100644 tests/test_php_use_imports.py diff --git a/graphify/extract.py b/graphify/extract.py index 447ae4021c..df1403467d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -106,6 +106,8 @@ _pascal_resolve_class, _pascal_resolve_unit, _pascal_unit_cache, + _php_use_clause_context, + _php_use_clause_fact, _pnpm_workspace_globs, _python_call_identifier, _python_import_from_module, @@ -662,23 +664,32 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: - for child in node.children: - if child.type in ("qualified_name", "name", "identifier"): - raw = _read_text(child, source) - module_name = raw.split("\\")[-1].strip() - if module_name: - tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) - break + # `node` is a single `namespace_use_clause`; the group-use prefix and the + # `function`/`const` keyword of a group use live on the parent declaration, + # so the clause alone cannot spell its own FQN (#19). Shared parser with + # `_resolve_php_type_references` — see resolution.py. + fact = _php_use_clause_fact(node, source, *_php_use_clause_context(node, source)) + if fact is None: + return + target_fqn, alias, use_kind = fact + # The edge target stays keyed on the imported short name: re-pointing it is + # the resolvers' job (`_resolve_php_type_references`), not the capture's. + module_name = target_fqn.rsplit("\\", 1)[-1].strip() + if not module_name: + return + edges.append({ + "source": file_nid, + "target": _make_id(module_name), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "metadata": sanitize_metadata({k: v for k, v in + {"use_kind": use_kind, "alias": alias, "target_fqn": target_fqn}.items() + if v is not None}), + }) # ── C/C++ function name helpers ─────────────────────────────────────────────── diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 097c32b6a7..60b35f9f8b 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2414,6 +2414,108 @@ def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: return f"{ns}\\{raw}" if ns else raw +# ── Shared PHP `use`-statement parser (#19) ─────────────────────────────────── +# One parser for both consumers: the `uses_by_file` map below and the `imports` +# edge capture in `_import_php` (extract.py). Group use `use A\{B, C as X};`, +# aliases, leading-backslash absolutes and `use function` / `use const` are all +# handled here so neither consumer has to re-derive them. + +def _php_use_clause_fact( + clause, + source: bytes, + prefix: str = "", + kind: str = "class", +) -> tuple[str, str | None, str] | None: + """Parse one ``namespace_use_clause`` into ``(target_fqn, alias, use_kind)``. + + ``prefix`` is the group-use prefix (empty for a standalone clause) and + ``kind`` the declaration-level ``function``/``const`` keyword, if any; a + clause-level keyword overrides it. Returns ``None`` when the clause names + no target (e.g. a parse error). + """ + target: str | None = None + alias: str | None = None + saw_as = False + for c in clause.children: + if c.type in ("function", "const"): + kind = c.type + elif c.type == "as": + saw_as = True + elif c.type in ("qualified_name", "name"): + if saw_as: + alias = _read_text(c, source) + elif target is None: + target = _read_text(c, source) + if not target: + return None + fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") + return fqn, alias, kind + + +def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: + """``(group prefix, use kind)`` a ``namespace_use_clause`` inherits from its + parent ``namespace_use_declaration``. + + For consumers that are dispatched per clause and never see the declaration + (`_import_php`). The prefix only applies to clauses inside a + ``namespace_use_group``; a standalone clause carries its own full name. + """ + parent = getattr(clause, "parent", None) + in_group = parent is not None and parent.type == "namespace_use_group" + decl = parent.parent if in_group else parent + prefix, kind = "", "class" + if decl is None or decl.type != "namespace_use_declaration": + return prefix, kind + for c in decl.children: + if c.type == "namespace_name" and in_group: + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + return prefix, kind + + +def _php_use_declaration_facts( + decl, + source: bytes, + *, + apply_declaration_kind: bool = True, +) -> list[tuple[str, str | None, str]]: + """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. + + ``use function A\\f;`` puts the keyword on the *clause*, while + ``use function A\\{f, g};`` puts it on the *declaration*. + ``_resolve_php_type_references`` has only ever honored the clause-level + keyword, so it passes ``apply_declaration_kind=False`` to keep that + behavior byte-identical (#19 is metadata-only). New consumers should leave + it on. + """ + prefix, kind, group = "", "class", None + direct = [] + for c in decl.children: + if c.type == "namespace_name": + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + if apply_declaration_kind: + kind = c.type + elif c.type == "namespace_use_group": + group = c + elif c.type == "namespace_use_clause": + direct.append(c) + + facts: list[tuple[str, str | None, str]] = [] + for c in direct: + fact = _php_use_clause_fact(c, source, "", kind) + if fact: + facts.append(fact) + if group is not None: + for c in group.children: + if c.type == "namespace_use_clause": + fact = _php_use_clause_fact(c, source, prefix, kind) + if fact: + facts.append(fact) + return facts + + def _resolve_php_type_references( per_file: list[dict], paths: list[Path], @@ -2473,27 +2575,6 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: - target = None - alias = None - saw_as = False - for c in clause.children: - if c.type in ("function", "const"): - return # not a class import - if c.type == "as": - saw_as = True - elif c.type in ("qualified_name", "name"): - if saw_as: - alias = _read_text(c, source) - elif target is None: - target = _read_text(c, source) - if not target: - return - fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") - key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() - if key: - uses.setdefault(key, fqn) - def walk(n) -> None: t = n.type if t == "namespace_definition": @@ -2502,19 +2583,14 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - prefix = "" - group = None - for c in n.children: - if c.type == "namespace_name": - prefix = _read_text(c, source) # group-use prefix - elif c.type == "namespace_use_group": - group = c - elif c.type == "namespace_use_clause": - _record_use_clause(c, "") - if group is not None: - for c in group.children: - if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + for fqn, alias, use_kind in _php_use_declaration_facts( + n, source, apply_declaration_kind=False + ): + if use_kind != "class": + continue # `use function` / `use const` are not class imports + key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if key: + uses.setdefault(key, fqn) return elif t == "class_declaration": for child in n.children: diff --git a/tests/test_php_use_imports.py b/tests/test_php_use_imports.py new file mode 100644 index 0000000000..ae556cf3e4 --- /dev/null +++ b/tests/test_php_use_imports.py @@ -0,0 +1,152 @@ +"""PHP `use`-import capture (#19). + +Every assertion goes through the public `extract()` seam. These are +metadata-shape tests: the `imports` edges themselves (their targets) must stay +exactly as they were — only `target_fqn` / `alias` / `use_kind` are new. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _import_facts(result: dict) -> set[tuple[str | None, str | None, str | None]]: + """(target_fqn, alias, use_kind) for every `imports` edge in the graph.""" + facts = set() + for e in result["edges"]: + if e.get("relation") != "imports": + continue + md = e.get("metadata") or {} + facts.add((md.get("target_fqn"), md.get("alias"), md.get("use_kind"))) + return facts + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def test_php_plain_use_captures_target_fqn(tmp_path: Path): + f = _write( + tmp_path / "app/Http/I.php", + " Date: Wed, 5 Aug 2026 19:58:11 -0300 Subject: [PATCH 17/41] fix(extract): scope member-call resolvers to the sources they own (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swift, Python and TypeScript member-call resolvers skipped raw calls carrying a `lang` tag, and each site claimed that guard kept another language's data out. It did not: `lang` is stamped only on the cpp, csharp, java, objc and php raw calls, and those three resolvers are themselves untagged — so they consumed each other's raw calls. A TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. A negative tag check cannot close that by construction. Replace it with a positive source-file suffix filter (prior art: `ruby_resolution._ruby_raw_calls`) and correct the comments at all three sites. The tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix alone cannot tell their raw calls apart. Also language-scope the Java and C# `type_def_nids` indexes, the last unfixed copies of the shape #8 fixed for PHP/ObjC: an unscoped index let a Python `class Lead` type the receiver of a Java `Lead lead; lead.search()` at INFERRED, and let a foreign class merely sharing the name push the single-definition guard to 2 and suppress the correct same-language edge. The suffix tuples now feed both the resolver registration and the scoping, so the two cannot drift. Pre-existing defects, not regressions from the #1682 work. Tests: 8 new mixed-corpus cases through the `extract()` seam, each built so only one resolver can be the miner — the resolver that owns the raw call refuses it on its own terms, so any surviving edge is a foreign resolver reaching across. 7 of the 8 were red before this change. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +- graphify/extract.py | 116 ++++++++--- tests/test_mixed_corpus_member_calls.py | 256 +++++++++++++++++++++++- 3 files changed, 337 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c40030a5e..ad6f9545b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu - 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. +- 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 Java and C# halves are fixed by the next bullet, and the same exposure in the C++, Swift, TypeScript and Python resolvers is untouched and left as a follow-up (`lawnstarter/graphify#24`). +- Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is tracked as `lawnstarter/graphify#24`. - 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`). +- 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`); 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) diff --git a/graphify/extract.py b/graphify/extract.py index 447ae4021c..e7eb660024 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2253,6 +2253,36 @@ def _assembly_of_node(nid: str) -> str: rc["caller_nid"] = remap[cn] +# Source suffixes each member-call resolver owns. Used BOTH to register the +# resolver and to scope what it consumes (#8, #10) — one definition so the two +# cannot drift. +_SWIFT_RESOLVER_SUFFIXES = (".swift",) +_PYTHON_RESOLVER_SUFFIXES = (".py",) +_TYPESCRIPT_RESOLVER_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx") +_CSHARP_RESOLVER_SUFFIXES = (".cs",) +_JAVA_RESOLVER_SUFFIXES = (".java",) +_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 _raw_call_is_owned(rc: dict, suffixes: tuple[str, ...]) -> bool: + """True when ``rc`` was written in a source file with one of ``suffixes``. + + How the three untagged resolvers (Swift, Python, TypeScript) claim their raw + calls (#10). The tagged languages (cpp, csharp, java, objc, php) match on the + extractor-stamped ``lang`` instead, because C++ and ObjC share `.h` and a + suffix alone cannot tell their raw calls apart. Prior art for the suffix + form: ``ruby_resolution._ruby_raw_calls``. + """ + return str(rc.get("source_file") or "").lower().endswith(suffixes) + + def _resolve_swift_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -2315,10 +2345,12 @@ 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"): + # Consume only raw calls written in Swift sources (#10). The former + # `if rc.get("lang"): continue` excluded the TAGGED languages (cpp, + # csharp, java, objc, php) but not the untagged ones — Python and + # TypeScript raw calls carry no `lang` either, so they flowed straight + # into the arms below. A positive suffix filter is closed by construction. + if not _raw_call_is_owned(rc, _SWIFT_RESOLVER_SUFFIXES): continue if not rc.get("is_member_call"): continue @@ -2481,10 +2513,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"): + # Consume only raw calls written in Python sources (#10). The former + # `if rc.get("lang"): continue` let every untagged language through: a + # TypeScript `Lead.search({})` reached the class arm below and minted an + # EXTRACTED edge into a Python method with no TS `Lead` in the corpus. + if not _raw_call_is_owned(rc, _PYTHON_RESOLVER_SUFFIXES): continue if not rc.get("is_member_call"): continue @@ -2570,10 +2603,12 @@ 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"): + # Consume only raw calls written in TS/JS sources (#10). The former + # `if rc.get("lang"): continue` excluded only the tagged languages, so a + # Python or Swift raw call still reached the arms below — and this + # resolver's `references` fallback minted a call-context edge onto the + # receiver's type even when no method matched. + if not _raw_call_is_owned(rc, _TYPESCRIPT_RESOLVER_SUFFIXES): continue if not rc.get("is_member_call"): continue @@ -2789,11 +2824,18 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to C# sources (#10), the same fix #8 made for PHP/ObjC. An unscoped + # index matched a C# receiver type against classes written in ANY language, + # which cut both ways: a Python `class Lead` could type the receiver, and a + # Python class merely SHARING the name pushed the single-definition guard to + # 2 and silently suppressed the correct C# 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(_CSHARP_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"]) # Namespace/using/alias-aware simple-name resolution, shared with the C# @@ -2973,10 +3015,14 @@ def key(label: str) -> str: if edge.get("relation") == "contains"} node_by_id = {node.get("id"): node for node in all_nodes} + # Scoped to Java sources (#10), the same fix #8 made for PHP/ObjC: an + # unscoped index let a Python `class Lead` type the receiver of + # `Lead lead; lead.search()` at INFERRED, and let a foreign class merely + # sharing the name suppress the correct Java 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(_JAVA_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -3043,18 +3089,6 @@ 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, @@ -3408,10 +3442,18 @@ def _key(label: str) -> str: # by adding one register() call below — no edits to extract()'s body. Order # preserved from the prior inlined wiring: Swift (#1356) before Python (#1446). register_language_resolver( - LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls) + LanguageResolver( + "swift_member_calls", + frozenset(_SWIFT_RESOLVER_SUFFIXES), + _resolve_swift_member_calls, + ) ) register_language_resolver( - LanguageResolver("python_member_calls", frozenset({".py"}), _resolve_python_member_calls) + LanguageResolver( + "python_member_calls", + frozenset(_PYTHON_RESOLVER_SUFFIXES), + _resolve_python_member_calls, + ) ) # Ruby type-aware member-call resolution (Class.new + typed var.method). Lives in # graphify.ruby_resolution; registered here as a second consumer of the framework. @@ -3419,7 +3461,11 @@ def _key(label: str) -> str: LanguageResolver("ruby_member_calls", frozenset({".rb", ".rake"}), resolve_ruby_member_calls) ) register_language_resolver( - LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"}), _resolve_typescript_member_calls) + LanguageResolver( + "typescript_member_calls", + frozenset(_TYPESCRIPT_RESOLVER_SUFFIXES), + _resolve_typescript_member_calls, + ) ) # C++ (#1547) and ObjC (#1556) receiver-typed member-call resolution. `.h` is in # both suffix sets because it routes to extract_cpp or extract_objc by content; the @@ -3441,10 +3487,18 @@ def _key(label: str) -> str: # C# receiver-typed member-call resolution (#1609): `field/param/local.Method()` # bound to the receiver's declared type instead of a bare same-named match. register_language_resolver( - LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls) + LanguageResolver( + "csharp_member_calls", + frozenset(_CSHARP_RESOLVER_SUFFIXES), + _resolve_csharp_member_calls, + ) ) register_language_resolver( - LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) + LanguageResolver( + "java_member_calls", + frozenset(_JAVA_RESOLVER_SUFFIXES), + _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. diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py index 3fb09f3084..33449b0f74 100644 --- a/tests/test_mixed_corpus_member_calls.py +++ b/tests/test_mixed_corpus_member_calls.py @@ -1,14 +1,20 @@ -"""Mixed-corpus isolation for the member-call resolvers (#6, spec #1682). +"""Mixed-corpus isolation for the member-call resolvers (#6, #8, #10, 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. +A corpus that mixes languages must not let one language's raw call data mint an +edge through a different language's member-call resolver. Two independent +mechanisms have to hold for that: + +* **Raw-call ownership** -- a resolver consumes only raw calls from source files + it owns. The cpp/csharp/java/objc/php resolvers claim theirs by the + extractor-stamped ``lang``; Swift, Python and TypeScript raw calls carry no + tag, so those three filter by source-file suffix instead (#10). +* **Definition-index scoping** -- the receiver-type index a resolver builds + holds only types declared in its own sources (#8 for PHP/ObjC, #10 for + Java/C#). 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. +so a bare method-name match cannot tell it apart from the target. """ from __future__ import annotations @@ -43,6 +49,21 @@ def _nid(result: dict, label: str, file_suffix: str) -> str: ) +def _call_context_pairs(result: dict) -> set[tuple[str, str]]: + """Every ``context: "call"`` edge as (source, target) pairs. + + Wider than ``_calls``: the Swift and TypeScript resolvers fall back to a + ``references`` edge onto the receiver's TYPE when the type has no such + method, so a leak through either of them can surface as ``references`` + rather than ``calls``. + """ + return { + (edge["source"], edge["target"]) + for edge in result["edges"] + if edge.get("context") == "call" + } + + # 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 = ( @@ -221,3 +242,224 @@ def test_objc_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path assert (go, objc_search) in calls, \ "a same-named Python class suppressed the real ObjC edge" assert (go, py_search) not in calls + + +# ── Untagged resolvers consume each other's raw calls (#10) ────────────────── +# +# The `lang` tag the tests above rely on is stamped only on cpp/csharp/java/ +# objc/php raw calls. Swift, Python and TypeScript raw calls carry none, so a +# `if rc.get("lang"): continue` guard in those three resolvers excluded the +# tagged languages and let the untagged ones flow into each other: a TypeScript +# receiver reached the Python resolver's capitalized-receiver class arm and +# minted a cross-language edge at EXTRACTED. The guard cannot close that by +# construction; only a positive source-file suffix filter can. +# +# Each test below is built so that exactly one resolver can be the miner: the +# resolver that legitimately owns the raw call refuses it on its own terms, so +# any surviving edge is another language's resolver reaching across. + +_TS_TYPED_RECEIVER = ( + "class Dep { go() { return 1; } }\n" + "export class Widget {\n" + " constructor(private dep: Dep) {}\n" + " run() { return this.dep.go(); }\n" + "}\n" +) +"""A TS constructor parameter property. Produces the `ts_type_table` the +TypeScript resolver requires -- without one it returns early and cannot leak.""" + +_SWIFT_TYPED_RECEIVER = ( + "class Dep { func go() {} }\n" + "class Widget {\n" + " let dep: Dep = Dep()\n" + " func run() { dep.go() }\n" + "}\n" +) +"""The Swift twin: produces the `swift_type_table` the Swift resolver requires.""" + +# Calls a method the Python class does not own, so the Python resolver itself +# refuses (its method index misses) -- any edge onto `Lead` is foreign. +_PY_CALLER_MISSING_METHOD = ( + "from svc import Lead\n" + "\n" + "\n" + "def run():\n" + " Lead.missing()\n" +) + + +def test_typescript_receiver_mints_no_edge_into_a_python_method(tmp_path: Path): + """The reported repro (#10): no TS `Lead` exists anywhere in the corpus. + + `Lead.search({})` is a TypeScript raw call. Read as a Python raw call it + hits the Python resolver's capitalized-receiver class arm and binds to the + Python `Lead.search` at EXTRACTED -- the strongest confidence label -- for + a call written in a file the Python resolver does not own. + """ + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "runner.ts": "class Runner {\n go() { return Lead.search({}); }\n}\n", + }) + + go = _nid(result, ".go()", "runner.ts") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a TypeScript raw call minted an edge into a Python method" + + +def test_python_raw_call_is_not_mined_by_the_typescript_resolver(tmp_path: Path): + """The reverse direction: a Python raw call reaching the TS resolver. + + `Lead.missing()` is refused by the Python resolver -- the Python `Lead` has + no `missing`. The TypeScript resolver, handed the same raw call, resolves + the receiver type and falls back to a `references` edge onto the class, + inventing a call-context edge out of Python source. + """ + _, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "caller.py": _PY_CALLER_MISSING_METHOD, + "widget.ts": _TS_TYPED_RECEIVER, + }) + pairs = _call_context_pairs(result) + + run = _nid(result, "run()", "caller.py") + py_lead = _nid(result, "Lead", "svc.py") + assert (run, py_lead) not in pairs, \ + "the TypeScript resolver mined a Python raw call" + # Positive control: the TS resolver still resolves its own typed receiver. + ts_run = _nid(result, ".run()", "widget.ts") + ts_go = _nid(result, ".go()", "widget.ts") + assert (ts_run, ts_go) in pairs, "the TypeScript resolver stopped resolving" + + +def test_python_raw_call_is_not_mined_by_the_swift_resolver(tmp_path: Path): + """Same shape, Swift twin: `Lead.missing()` is Python's raw call to refuse.""" + _, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "caller.py": _PY_CALLER_MISSING_METHOD, + "Widget.swift": _SWIFT_TYPED_RECEIVER, + }) + pairs = _call_context_pairs(result) + + run = _nid(result, "run()", "caller.py") + py_lead = _nid(result, "Lead", "svc.py") + assert (run, py_lead) not in pairs, \ + "the Swift resolver mined a Python raw call" + # Positive control: the Swift resolver still resolves its own typed receiver. + swift_run = _nid(result, ".run()", "Widget.swift") + swift_go = _nid(result, ".go()", "Widget.swift") + assert (swift_run, swift_go) in pairs, "the Swift resolver stopped resolving" + + +def test_swift_raw_call_is_not_mined_by_the_python_resolver(tmp_path: Path): + """A Swift raw call the Swift resolver refuses must not reach Python. + + `Bundle` is in `_LANGUAGE_BUILTIN_GLOBALS`, so the Swift resolver skips the + receiver outright (#2147) rather than binding a same-named user symbol. The + Python resolver has no such guard, so the Swift raw call flowed into its + class arm and bound to a Python `class Bundle` at EXTRACTED. + """ + calls, result = _calls(tmp_path, { + "svc.py": "class Bundle:\n def load(self):\n return []\n", + "Runner.swift": "class Runner {\n func go() { Bundle.load() }\n}\n", + "Widget.swift": _SWIFT_TYPED_RECEIVER, + }) + + go = _nid(result, ".go()", "Runner.swift") + py_load = _nid(result, ".load()", "svc.py") + assert (go, py_load) not in calls, \ + "a Swift raw call minted an edge into a Python method" + # Positive control, with a decoy: the Swift resolver still resolves its own + # typed receiver and does not fall back to a bare method-name match. + swift_run = _nid(result, ".run()", "Widget.swift") + swift_go = _nid(result, ".go()", "Widget.swift") + assert (swift_run, swift_go) in calls, "the Swift resolver stopped resolving" + assert (swift_run, go) not in calls, "the decoy Swift class received an edge" + + +# ── Language-scoped Java and C# receiver type indexes (#10) ────────────────── +# +# The definition-index half of the family #8 fixed for PHP/ObjC and recorded as +# knowingly deferred elsewhere. Java and C# still built `type_def_nids` from +# every type-like node in the corpus, so a Java `Lead lead; lead.search()` +# bound to a Python `class Lead` at INFERRED. + + +def test_java_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1, Java: no Java `class Lead` exists, only a Python one.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Runner.java": ( + "class Runner {\n" + " private Lead lead;\n" + " void go() { lead.search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.java") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a Java receiver type must not resolve against a Python class" + + +def test_java_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2, Java: the cross-language name collision pushed the + single-definition guard to 2 and suppressed the legitimate Java edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Lead.java": "class Lead { void search() {} }\n", + "Runner.java": ( + "class Runner {\n" + " private Lead lead;\n" + " void go() { lead.search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.java") + java_search = _nid(result, ".search()", "Lead.java") + py_search = _nid(result, ".search()", "svc.py") + assert (go, java_search) in calls, \ + "a same-named class in another language suppressed the real Java edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, java_search)]["confidence"] == "INFERRED" + + +def test_csharp_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1, C#: no C# `class Lead` exists, only a Python one.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Runner.cs": ( + "public class Runner {\n" + " private Lead lead;\n" + " public void Go() { lead.search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".Go()", "Runner.cs") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a C# receiver type must not resolve against a Python class" + + +def test_csharp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2, C#: the legitimate C#-to-C# edge survives the collision.""" + calls, result = _calls(tmp_path, { + "svc.py": "class Lead:\n def Search(self):\n return []\n", + "Lead.cs": "public class Lead { public void Search() {} }\n", + "Runner.cs": ( + "public class Runner {\n" + " private Lead lead;\n" + " public void Go() { lead.Search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".Go()", "Runner.cs") + cs_search = _nid(result, ".Search()", "Lead.cs") + py_search = _nid(result, ".Search()", "svc.py") + assert (go, cs_search) in calls, \ + "a same-named class in another language suppressed the real C# edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" From 45f7de165a0e5c151e4dbb392cd3041f4624f002 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 20:22:41 -0300 Subject: [PATCH 18/41] fix(php): stop group-form `use function`/`use const` from claiming class names (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_php_type_references` builds its per-file class-name map from `use` statements and correctly refuses `use function` / `use const` — but only when the keyword sits on the CLAUSE. tree-sitter-php (0.24.1) puts it on the DECLARATION for the group form, and the pass only ever iterated clause children, so `use function Vendor\Sdk\{Render};` registered `Render` as a claimed class name. Any `inherits` / `implements` / `mixes_in` / `imports` / `references` to that short name in the same file was then treated as an explicit import and re-pointed onto an FQN-labeled external stub — a stub naming a function or a constant, not a class. #19 already extracted the parser into `_php_use_declaration_facts`, which reads the declaration-level keyword and reports the right `use_kind`; the `imports`-edge capture consumes it that way today. This pass was the only caller passing `apply_declaration_kind=False`, purely to keep #19's metadata-only constraint byte-exact. Drop the argument and delete the flag, so one code path serves both consumers and cannot drift again. Strictly subtractive: it can only remove a class-name claim, which makes the affected reference fall back to the namespace-relative FQN or to the legacy unique-label rewire — the same place the unbraced spelling has always left it. Worth landing before #21's decisive-refusal work, where a wrongly claimed short name would become a wrongly decisive refusal. Pre-existing defect, not a regression from #19. Tests go through the public `extract()` seam with the plain form as the side-by-side control in the same test: group-vs-plain for `use function` and for `use const` (both spellings must land on the same bare stub, and no `Vendor\Sdk\*` class stub may be minted), every member of a multi-member group rejected, and an over-subtraction guard — a keyword-less group `use App\Cms\{Page};` still claims its member and resolves past a decoy `App\Models\Page`, which gets no edge. 3 of the 4 were red before this change; the guard was green both sides. Suite: 4061 passed / 36 skipped (baseline 4057 / 36 plus 4 new). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/resolution.py | 16 +-- tests/test_php_group_use_kind.py | 156 ++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 tests/test_php_group_use_kind.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ca476f139f..71b366e53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. +- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. - 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) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 60b35f9f8b..dba70335e9 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2477,17 +2477,12 @@ def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: def _php_use_declaration_facts( decl, source: bytes, - *, - apply_declaration_kind: bool = True, ) -> list[tuple[str, str | None, str]]: """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. ``use function A\\f;`` puts the keyword on the *clause*, while - ``use function A\\{f, g};`` puts it on the *declaration*. - ``_resolve_php_type_references`` has only ever honored the clause-level - keyword, so it passes ``apply_declaration_kind=False`` to keep that - behavior byte-identical (#19 is metadata-only). New consumers should leave - it on. + ``use function A\\{f, g};`` puts it on the *declaration* — both spellings + yield ``use_kind == "function"`` here (#26). """ prefix, kind, group = "", "class", None direct = [] @@ -2495,8 +2490,7 @@ def _php_use_declaration_facts( if c.type == "namespace_name": prefix = _read_text(c, source) elif c.type in ("function", "const"): - if apply_declaration_kind: - kind = c.type + kind = c.type elif c.type == "namespace_use_group": group = c elif c.type == "namespace_use_clause": @@ -2583,9 +2577,7 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - for fqn, alias, use_kind in _php_use_declaration_facts( - n, source, apply_declaration_kind=False - ): + for fqn, alias, use_kind in _php_use_declaration_facts(n, source): if use_kind != "class": continue # `use function` / `use const` are not class imports key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() diff --git a/tests/test_php_group_use_kind.py b/tests/test_php_group_use_kind.py new file mode 100644 index 0000000000..e4860101db --- /dev/null +++ b/tests/test_php_group_use_kind.py @@ -0,0 +1,156 @@ +"""Group-form `use function` / `use const` must not claim class names (#26). + +tree-sitter-php puts the `function` / `const` keyword on the *clause* for the +plain form (`use function A\\f;`) but on the *declaration* for the group form +(`use function A\\{f, g};`). `_resolve_php_type_references` only ever inspected +the clause, so group-form members wrongly entered the per-file class-name map +and re-pointed supertype references onto an FQN-labeled external stub. + +Every assertion goes through the public `extract()` seam, with the semantically +equivalent plain form as the side-by-side control. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def _targets(result: dict, relation: str, source_substr: str) -> list[dict]: + """Target nodes of every `relation` edge coming out of a matching source.""" + return [ + _node_by_id(result, e["target"]) + for e in result["edges"] + if e.get("relation") == relation + and source_substr in e.get("source", "").lower() + ] + + +def test_php_group_use_function_behaves_like_the_plain_form(tmp_path: Path): + # `use function Vendor\Sdk\{Render};` imports a *function*, so `Render` in a + # class position is not an explicitly imported class name. The braced form + # must land exactly where the unbraced control lands: on the bare stub the + # legacy unique-label rewire owns, never on an FQN-labeled external stub. + group = _write( + tmp_path / "app/A/UsesGroup.php", + " Date: Wed, 5 Aug 2026 20:26:51 -0300 Subject: [PATCH 19/41] feat(extract): keep written qualified PHP receiver types (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_php_name_text` flattens every written PHP type annotation to its short name, so `private \Vendor\Sdk\Client $c;` was indistinguishable from `private Client $c;` — the compounding half of the #16 false-edge bug. Only inline-`new` kept the written form (`receiver_qualified`). Thread the written qualified form alongside the short name through the receiver-type table for all four annotation positions that type a receiver — properties, constructor-promoted params, ordinary params and `new`-bound locals — and stamp it on the raw-call fact as `receiver_type_qualified`. The table's values become a `_PhpReceiverType` (short, qualified) pair; `qualified` is set only when the annotation carried a namespace separator, so unqualified annotations produce the facts they produced before. Strictly additive: every decision — binding, poisoning, the #9 multi-class refusal (key present, value None) and the resolver's short-name lookup — is still taken on the short name alone. Two `new`s naming the same short name through different written forms keep today's binding and drop the conflicting qualified evidence rather than poisoning the name. Nothing consults the new field yet; the decisive refusal that closes #16 is #21. Verified beyond the suite: over a PHP corpus exercising all four positions plus unions, inline-`new`, same-short-name decoys and the conflicting-written-forms case, the extract() graph is byte-identical to v8 @ e188ff6. --- graphify/extractors/engine.py | 127 +++++++---- tests/test_php_qualified_receiver_types.py | 254 +++++++++++++++++++++ 2 files changed, 341 insertions(+), 40 deletions(-) create mode 100644 tests/test_php_qualified_receiver_types.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 184e5e31f5..a77ab63953 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -9,6 +9,7 @@ from graphify.extractors.resolution import _resolve_js_import_target from graphify.security import sanitize_metadata from pathlib import Path +from typing import NamedTuple def _csharp_namespace_id(dotted_name: str) -> str: @@ -714,29 +715,50 @@ def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s }) -def _php_concrete_type_name(type_node, source: bytes) -> str | None: - """Single concrete class name of a PHP type expression, or None (= refuse). +class _PhpReceiverType(NamedTuple): + """A receiver's declared type: the short name plus the WRITTEN form (#20). + + `short` is what the resolver has always matched on — namespace-stripped, so + `\\Vendor\\Sdk\\Client` and a bare `Client` are the same key. `qualified` is + the annotation exactly as written, and ONLY when it carries a namespace + separator: an unqualified annotation leaves it None, so its facts stay + identical to the pre-#20 ones. It is independent evidence about WHICH + same-short-named class was meant — the fact the use-import resolver (#21) + needs and the flattening in `_php_name_text` used to destroy. + """ + short: str + qualified: str | None + + +def _php_written_type(name_node, source: bytes) -> _PhpReceiverType | None: + """Pair a PHP `name`/`qualified_name` node's short name with its written text.""" + short = _php_name_text(name_node, source) + if not short or short.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: + return None + written = _read_text(name_node, source) + return _PhpReceiverType(short, written if "\\" in written else None) + + +def _php_concrete_type(type_node, source: bytes) -> _PhpReceiverType | None: + """Single concrete class named by a PHP type expression, or None (= refuse). Deliberately NOT `_php_collect_type_refs`: that helper flattens a union into several refs, whereas a receiver typed `A|B` has no single type and must be - refused. `named_type` yields its namespace-stripped name; a nullable wrapper - around exactly one type unwraps (`?Foo` is still concretely Foo); union, - intersection, primitive and missing types yield None (#1682). + refused. `named_type` yields its name (short + written, #20); a nullable + wrapper around exactly one type unwraps (`?Foo` is still concretely Foo); + union, intersection, primitive and missing types yield None (#1682). """ if type_node is None: return None if type_node.type == "named_type": for c in type_node.children: if c.type in ("name", "qualified_name"): - text = _php_name_text(c, source) - if text and text.lower() not in _PHP_NON_CONCRETE_TYPE_NAMES: - return text - return None + return _php_written_type(c, source) return None if type_node.type in ("optional_type", "nullable_type"): inner = [c for c in type_node.named_children if c.type != "comment"] if len(inner) == 1: - return _php_concrete_type_name(inner[0], source) + return _php_concrete_type(inner[0], source) return None @@ -752,7 +774,7 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: def _php_multi_typed_annotation(type_node) -> bool: """True when a PHP type annotation names more than one candidate class (#9). - `_php_concrete_type_name` refuses several shapes, and the two reasons for + `_php_concrete_type` refuses several shapes, and the two reasons for refusal must be told apart at the call site (user story 11): a receiver whose annotation is a UNION or INTERSECTION provably has several possible classes, so an in-file bare-name match can only pick one of them by file order and @@ -787,8 +809,8 @@ def _php_multi_typed_annotation(type_node) -> bool: def _php_method_receiver_types( method_node, source: bytes, - field_types: dict[str, str | None], -) -> dict[str, str | None]: + field_types: dict[str, _PhpReceiverType | None], +) -> dict[str, _PhpReceiverType | None]: """Build the receiver type table visible to one PHP method (#1682). ``this.`` keys come from the declaring class's typed properties and @@ -809,11 +831,15 @@ def _php_method_receiver_types( ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > multi-class refusal > absent, so a union-typed param later assigned a `new T()` still resolves to T, while a poisoned union-typed one stays refused. + + A resolved value carries both the short name and the written qualified form + (#20); every decision below is taken on the SHORT name alone, so the table's + keys and their resolved/refused/absent status are exactly what they were. """ - table: dict[str, str | None] = { + table: dict[str, _PhpReceiverType | None] = { f"this.{name}": type_name for name, type_name in field_types.items() } - method_types: dict[str, str] = {} + method_types: dict[str, _PhpReceiverType] = {} multi_typed_params: set[str] = set() ambiguous: set[str] = set() @@ -822,14 +848,20 @@ def poison(name: str) -> None: method_types.pop(name, None) ambiguous.add(name) - def bind(name: str, type_name: str | None) -> None: + def bind(name: str, declared: _PhpReceiverType | None) -> None: if not name or name in ambiguous: return previous = method_types.get(name) - if type_name is None or (previous is not None and previous != type_name): + if declared is None or (previous is not None and previous.short != declared.short): poison(name) + elif previous is not None and previous.qualified != declared.qualified: + # Same short name written two ways (`new Client()` then + # `new \Vendor\Client()`): the SHORT binding is what it always was, + # so keep it. Only the qualified evidence conflicts — drop that + # rather than poison a name today's table still types (#20). + method_types[name] = _PhpReceiverType(previous.short, None) else: - method_types[name] = type_name + method_types[name] = declared def poison_bound_vars(node) -> None: """Poison every ``$var`` named anywhere in a binding-site subtree. @@ -847,18 +879,19 @@ def poison_bound_vars(node) -> None: continue stack.extend(n.children) - def new_type_name(node) -> str | None: - """Class named by an ``object_creation_expression``, or None.""" + def new_type_name(node) -> _PhpReceiverType | None: + """Class named by an ``object_creation_expression``, or None. + + `new self()` / `new static()` need inheritance context the raw-call + facts do not carry, so the non-concrete set refuses them. + """ if node is None or node.type != "object_creation_expression": return None cls = next((c for c in node.named_children if c.type in ("name", "qualified_name")), None) if cls is None: # `new class { … }` names nothing return None - text = _php_name_text(cls, source) - if not text or text.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: - return None # `new self()` / `new static()` need inheritance context - return text + return _php_written_type(cls, source) # Natively typed parameters. `variadic_parameter` is excluded on purpose: # `T ...$xs` binds an ARRAY of T, not a T. @@ -868,11 +901,11 @@ def new_type_name(node) -> str | None: if param.type not in ("simple_parameter", "property_promotion_parameter"): continue type_node = param.child_by_field_name("type") - type_name = _php_concrete_type_name(type_node, source) + declared = _php_concrete_type(type_node, source) name_node = param.child_by_field_name("name") - if name_node is not None and type_name: + if name_node is not None and declared: # Untyped / primitive params simply stay unbound. - bind(_read_text(name_node, source).lstrip("$"), type_name) + bind(_read_text(name_node, source).lstrip("$"), declared) elif name_node is not None and _php_multi_typed_annotation(type_node): # A union/intersection param is not BOUND — `bind(None)` would # poison it, and poisoning is indistinguishable from untyped. @@ -2702,7 +2735,7 @@ def _extract_generic( # `prop -> declared type` per class. A value of None means the annotation was # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call # site must tell apart from an ABSENT key = no annotation at all (#9). - php_field_types: dict[str, dict[str, str | None]] = {} + php_field_types: dict[str, dict[str, _PhpReceiverType | None]] = {} php_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() @@ -3552,17 +3585,18 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # concrete class name counts — unions/primitives are refused. # A multi-class annotation is recorded as None so the call site # can defer rather than bare-name match it (#9); every other - # refusal leaves the property out of the table entirely. - type_name = _php_concrete_type_name(c, source) + # refusal leaves the property out of the table entirely. A + # resolved type keeps the written qualified form too (#20). + declared = _php_concrete_type(c, source) multi_typed = _php_multi_typed_annotation(c) - if type_name or multi_typed: + if declared or multi_typed: fields = php_field_types.setdefault(parent_class_nid, {}) for pe in node.children: if pe.type != "property_element": continue v = pe.child_by_field_name("name") if v is not None: - fields[_read_text(v, source).lstrip("$")] = type_name + fields[_read_text(v, source).lstrip("$")] = declared refs: list[tuple[str, str]] = [] _php_collect_type_refs(c, source, False, refs) for ref_name, role in refs: @@ -3899,7 +3933,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # record it in the same `this.` receiver table, # multi-class annotations included as None (#9). if is_promoted and parent_class_nid: - promoted_type = _php_concrete_type_name(type_node, source) + promoted_type = _php_concrete_type(type_node, source) v = p.child_by_field_name("name") if v is not None and ( promoted_type or _php_multi_typed_annotation(type_node) @@ -4527,8 +4561,11 @@ def _php_class_const_scope(n) -> str | None: def walk_calls( node, caller_nid: str, - # PHP entries may map a key to None — see _php_method_receiver_types. - receiver_types: dict[str, str | None] | None = None, + # Java/C# values are plain short names; PHP values are a + # (short, qualified) pair and may be None — see + # _php_method_receiver_types. Body ids are language-disjoint, so one + # body's table only ever meets its own language's reader. + receiver_types: dict[str, str | _PhpReceiverType | None] | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -4909,17 +4946,21 @@ def walk_calls( # a PRESENT key mapped to None is a refused multi-class # annotation, an ABSENT key is no annotation at all. _php_receiver_type: str | None = None + # The annotation as WRITTEN, when it carried a namespace (#20). + # Stamped alongside the short name; nothing decides on it yet. + _php_receiver_qualified: str | None = None _php_multi_typed_receiver = False if config.ts_module == "tree_sitter_php": if php_inline_new_type: _php_receiver_type = php_inline_new_type elif member_receiver and member_receiver != "this": _php_types = receiver_types or {} - _php_receiver_type = _php_types.get(member_receiver) - _php_multi_typed_receiver = ( - _php_receiver_type is None - and member_receiver in _php_types - ) + _php_declared = _php_types.get(member_receiver) + if _php_declared is not None: + _php_receiver_type = _php_declared.short + _php_receiver_qualified = _php_declared.qualified + else: + _php_multi_typed_receiver = member_receiver in _php_types _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver if _java_defer or _php_defer or ( is_member_call @@ -5031,6 +5072,12 @@ def walk_calls( rc_entry["fcc"] = True if _php_receiver_type: rc_entry["receiver_type"] = _php_receiver_type + if _php_receiver_qualified: + # Written form of the ANNOTATION that typed the + # receiver (#20) — kept apart from the inline-`new` + # `receiver_qualified` below, which is the class + # named at the call itself. + rc_entry["receiver_type_qualified"] = _php_receiver_qualified if php_inline_new_qualified: rc_entry["receiver_qualified"] = php_inline_new_qualified raw_calls.append(rc_entry) diff --git a/tests/test_php_qualified_receiver_types.py b/tests/test_php_qualified_receiver_types.py new file mode 100644 index 0000000000..eac45a6552 --- /dev/null +++ b/tests/test_php_qualified_receiver_types.py @@ -0,0 +1,254 @@ +"""PHP qualified receiver types (#20). + +The extractor used to flatten every written PHP type annotation to its short +name, so `private \\Vendor\\Sdk\\Client $c;` was indistinguishable from +`private Client $c;` — the compounding half of the #16 false-edge bug. This +ticket threads the WRITTEN qualified form alongside the short name for the four +annotation positions that type a receiver (properties, constructor-promoted +params, ordinary params and `new`-bound locals) and stamps it on the raw-call +fact as `receiver_type_qualified`. + +Nothing consults the new field yet — the decisive refusal is #21 — so the +resolution tests here are PARITY tests: the short name still drives every edge, +bit for bit. The fact-shape tests use the per-file `extract_php` seam (the same +one `tests/test_ruby_resolution.py` uses for receiver-type facts), because the +raw-call facts are the extractor's output and never reach `{nodes, edges}`. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract, extract_php + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _facts(tmp_path: Path, body: str) -> list[dict]: + """Raw-call facts of one PHP file (the callee is never defined in it, so + every member call stays unresolved and reaches `raw_calls`).""" + path = _write(tmp_path / "app/Http/Controllers/LeadController.php", body) + return extract_php(path).get("raw_calls", []) + + +def _fact(facts: list[dict], receiver: str) -> dict: + matches = [f for f in facts if f.get("receiver") == receiver] + assert len(matches) == 1, facts + return matches[0] + + +def _class(members: str) -> str: + return ( + "c->send(); }\n" + )) + + fact = _fact(facts, "this.c") + assert fact["callee"] == "send" + assert fact["receiver_type"] == "Client" + assert fact["receiver_type_qualified"] == "\\Vendor\\Sdk\\Client" + + +def test_qualified_promoted_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function __construct(private \\App\\Services\\LeadHunterService $svc) {}\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(\\App\\Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_local_new_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_nullable_qualified_property_stamps_qualified(tmp_path: Path): + """`?\\A\\B` unwraps to one concrete type — the qualified form must survive + the unwrap, not just the short name.""" + facts = _facts(tmp_path, _class( + " private ?\\App\\Services\\LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_namespace_relative_annotation_stamps_written_form(tmp_path: Path): + """`Services\\X` is qualified but RELATIVE to the current namespace. The + extractor stamps what was written; resolving it is the resolver's job.""" + facts = _facts(tmp_path, _class( + " public function handle(Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "Services\\LeadHunterService" + + +# ── fact shape: unqualified annotations are unchanged ───────────────────────── + + +def test_unqualified_property_stamps_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " private LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +def test_unqualified_param_and_local_stamp_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(LeadHunterService $svc): array {\n" + " $other = new AuditLog();\n" + " return $svc->search([]) + $other->search([]);\n" + " }\n" + )) + + param = _fact(facts, "svc") + assert param["receiver_type"] == "LeadHunterService" + assert param.get("receiver_type_qualified") is None + local = _fact(facts, "other") + assert local["receiver_type"] == "AuditLog" + assert local.get("receiver_type_qualified") is None + + +def test_union_typed_receiver_stamps_neither_field(tmp_path: Path): + """A multi-class annotation is a refusal (#9): it stamps no type, and the + qualified field must not resurrect one.""" + facts = _facts(tmp_path, _class( + " public function handle(\\App\\A|\\App\\B $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert "receiver_type" not in fact + assert "receiver_type_qualified" not in fact + + +def test_same_class_written_two_ways_keeps_short_name(tmp_path: Path): + """Two `new`s naming the same SHORT name through different written forms + used to bind that short name, and still do — the qualified evidence is + contradictory, so it is dropped rather than poisoning the binding.""" + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new LeadHunterService();\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +# ── resolution parity through the public extract() seam ─────────────────────── + +_SERVICE = ( + " str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def test_qualified_annotation_still_resolves_by_short_name(tmp_path: Path): + """Parity: threading the qualified form changes no edge. The written FQN + names the in-corpus class here, and the resolution is exactly today's — + INFERRED 0.8 off the short name, decoy untouched.""" + calls, r = _extract(tmp_path, { + "app/Services/LeadHunterService.php": _SERVICE, + "app/Audit/AuditLog.php": _DECOY, + "app/Http/Controllers/LeadController.php": ( + "svc->search([]); }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 From 3e148efb1f5c0f03aee6fc1a8d1e02811edb0359 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 20:53:05 -0300 Subject: [PATCH 20/41] fix(php): refuse a member call whose receiver type the file claims (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PHP file that writes `use Vendor\Sdk\Client;` has said which `Client` it means, but `_resolve_php_member_calls` never read `use` statements: it bound the receiver's short type name through a corpus-wide index whose only refusal rule was "more than one candidate", so the lone unrelated `App\Local\Client` satisfied the single-definition guard and minted an INFERRED 0.8 edge into a class the file never imported (#16). `PhpNameResolver` mirrors `CsharpNameResolver`: it answers with a `(node_id, decisive)` verdict built from the `use` metadata on `imports` edges (#19), the declared-FQN payload (#14) and the same type-definition index the fallback uses, and is consulted in FRONT of that fallback exactly like the C# call site. A claimed name that lands on no in-corpus class refuses instead of falling back. Written qualified annotations (#20) resolve the same way, absolute or namespace-relative. Strictly subtractive by construction: every node the resolver returns is looked up under the receiver's WRITTEN short name in the very index the fallback consults, so a positive verdict is always the answer the fallback would have given, and the only behavior change is the refusal. Binding an alias to a class its short name does not name is a recall addition and stays with #22. Verified differentially against v8 over a 34-file corpus of PHP receiver-typing shapes: 3 edges deleted, none added, re-pointed or re-scored. The refusal needs no new persisted marker — the `use` map belongs to the calling file, which an incremental rebuild always re-dispatches — and a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so a class off its PSR-4 path keeps its edge on both paths. This is the fix for #16; the issue stays open for the orchestrator's gate. --- CHANGELOG.md | 3 +- graphify/extract.py | 78 +++---- graphify/extractors/php.py | 279 +++++++++++++++++++++++ tests/test_php_name_resolver.py | 393 ++++++++++++++++++++++++++++++++ 4 files changed, 700 insertions(+), 53 deletions(-) create mode 100644 graphify/extractors/php.py create mode 100644 tests/test_php_name_resolver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b366e53b..28f0c128e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the Java and C# halves are fixed by the next bullet, and the same exposure in the C++, Swift, TypeScript and Python resolvers is untouched and left as a follow-up (`lawnstarter/graphify#24`). - Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is tracked as `lawnstarter/graphify#24`. -- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. +- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. - Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. +- Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. - 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) diff --git a/graphify/extract.py b/graphify/extract.py index a9f7c11dfc..5531408e78 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -47,6 +47,10 @@ from graphify.extractors.json_config import extract_json # noqa: F401 from graphify.extractors.markdown import extract_markdown # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 +from graphify.extractors.php import ( # noqa: F401 + PhpNameResolver, + _php_qualified_corroborates, +) from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 @@ -3100,54 +3104,6 @@ def key(label: str) -> str: }) -def _php_qualified_corroborates( - qualified: str | None, - type_node: dict | None, - declared_fqn: str | None = None, -) -> bool: - """True when a source-written class name corroborates the resolved node (#1682). - - ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name - lookup that found the node ignored the namespace — so the namespace is - independent evidence, and only a match makes the edge EXTRACTED. - - ``declared_fqn`` is the name the DEFINING FILE declares for that class, - read from its ``namespace`` statement at extraction time (#14). When it is - known the comparison is whole-name: `\\App\\Services\\Client` corroborates - `App\\Services\\Client` and nothing else. Two things that used to promote - now do not — a file whose declared namespace disagrees with its PSR-4 path - (the written name then denotes a class that exists nowhere in the corpus), - and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT - class from `App\\Services\\Client` but matched as a path tail. - - Without a declaration — the file declares no namespace at all, or the node - came from a prior graph on an incremental run — the node's path is the only - corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto - ``app/Services/Svc.php``; every written segment must line up with the tail - of that path, case-insensitively. - - A BARE name (no namespace segment) corroborates nothing and stays INFERRED; - a namespace that does not line up downgrades rather than refusing, since the - class name itself still resolved unambiguously. - """ - if not qualified or not type_node: - return False - want = [seg.casefold() for seg in str(qualified).split("\\") if seg] - if len(want) < 2: - return False # bare `new Svc()`: no namespace written, no evidence - if declared_fqn: - have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] - return want == have # whole name, not a suffix of one - source_file = str(type_node.get("source_file") or "") - parts = [p for p in source_file.replace("\\", "/").split("/") - if p and p not in (".", "..")] - if not parts: - return False - parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension - parts = [p.casefold() for p in parts] - return len(parts) >= len(want) and parts[-len(want):] == want - - _PHP_NON_CLASS_TYPE_MARKERS = ("_php_non_class_types", "_php_interfaces") @@ -3267,6 +3223,14 @@ def key(label: str) -> str: if declared and declared.get("path"): class_fqn_by_file[declared["path"]] = declared.get("classes", {}) + # `use`-import/namespace-aware receiver typing (#21), consulted IN FRONT of + # the corpus-wide short-name index below — the shape of the C# call site in + # `_resolve_csharp_member_calls`. A name the calling file CLAIMS through a + # `use` import or writes out qualified is decided here and never falls back: + # that refusal is the whole fix for #16. It can only delete edges, never add + # or re-point one — see PhpNameResolver. + resolver = PhpNameResolver(all_nodes, all_edges, type_def_nids, class_fqn_by_file) + def declared_fqn(type_node: dict | None) -> str | None: """The namespace-qualified name of ``type_node``'s class, if its file declared one. Absent for a global-namespace class, and for a node @@ -3312,10 +3276,20 @@ def declared_fqn(type_node: dict | None) -> str | None: # type, and an enum's methods live on no definition node: # refuse rather than bind a same-short-named stranger. continue - type_defs = type_def_nids.get(key(type_name), []) - if len(type_defs) != 1: - continue # short name collides across the corpus: refuse - type_nid = type_defs[0] + resolved, decisive = resolver.resolve_type_name( + type_name, + raw_call.get("receiver_type_qualified"), + raw_call.get("source_file", ""), + ) + if resolved: + type_nid = resolved + elif decisive: + continue # the name is claimed and names no in-corpus class (#16) + else: + type_defs = type_def_nids.get(key(type_name), []) + if len(type_defs) != 1: + continue # short name collides across the corpus: refuse + type_nid = type_defs[0] if receiver == "(new)": # The class is named in the source; promote to EXTRACTED # only when the written namespace backs the node we found. diff --git a/graphify/extractors/php.py b/graphify/extractors/php.py new file mode 100644 index 0000000000..093b069d29 --- /dev/null +++ b/graphify/extractors/php.py @@ -0,0 +1,279 @@ +"""PHP cross-file resolution. + +The config-driven PHP *extractor* (``extract_php`` → ``_extract_generic``) still +lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the +config-driven languages cannot be ported one-by-one until the shared +``_extract_generic`` core moves as its own coordinated batch. This module is the +PHP home for the parts that *are* cleanly separable — today, the name-resolution +side of the member-call pass: matching a written class name against a definition +node, and the ``use``-import-aware receiver typing built on top of it. +""" +from __future__ import annotations + +from graphify.extractors.resolution import _php_fqn_from_raw + +# Mirrors `_PHP_RESOLVER_SUFFIXES` in graphify/extract.py. Kept local, like +# `_is_cs_file` in csharp.py, so this module imports nothing from the facade. +_PHP_SOURCE_SUFFIXES = ( + ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", +) + + +def _is_php_file(value: object) -> bool: + return isinstance(value, str) and value.lower().endswith(_PHP_SOURCE_SUFFIXES) + + +def _metadata(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _php_key(name: object) -> str: + """Fold a PHP class name: the language matches them case-insensitively.""" + return str(name).strip().casefold() + + +def _php_qualified_corroborates( + qualified: str | None, + type_node: dict | None, + declared_fqn: str | None = None, +) -> bool: + """True when a source-written class name corroborates the resolved node (#1682). + + ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name + lookup that found the node ignored the namespace — so the namespace is + independent evidence, and only a match makes the edge EXTRACTED. + + ``declared_fqn`` is the name the DEFINING FILE declares for that class, + read from its ``namespace`` statement at extraction time (#14). When it is + known the comparison is whole-name: `\\App\\Services\\Client` corroborates + `App\\Services\\Client` and nothing else. Two things that used to promote + now do not — a file whose declared namespace disagrees with its PSR-4 path + (the written name then denotes a class that exists nowhere in the corpus), + and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT + class from `App\\Services\\Client` but matched as a path tail. + + Without a declaration — the file declares no namespace at all, or the node + came from a prior graph on an incremental run — the node's path is the only + corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto + ``app/Services/Svc.php``; every written segment must line up with the tail + of that path, case-insensitively. + + A BARE name (no namespace segment) corroborates nothing and stays INFERRED; + a namespace that does not line up downgrades rather than refusing, since the + class name itself still resolved unambiguously. + """ + if not qualified or not type_node: + return False + want = [seg.casefold() for seg in str(qualified).split("\\") if seg] + if len(want) < 2: + return False # bare `new Svc()`: no namespace written, no evidence + if declared_fqn: + have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] + return want == have # whole name, not a suffix of one + source_file = str(type_node.get("source_file") or "") + parts = [p for p in source_file.replace("\\", "/").split("/") + if p and p not in (".", "..")] + if not parts: + return False + parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension + parts = [p.casefold() for p in parts] + return len(parts) >= len(want) and parts[-len(want):] == want + + +def _php_fqn_names_another_class( + fqn: str, + type_node: dict | None, + declared_fqn: str | None, +) -> bool: + """True when ``fqn`` provably names something OTHER than ``type_node`` (#21). + + The contrapositive of ``_php_qualified_corroborates``, with one deliberate + difference: *absent* evidence is not a contradiction. Otherwise the same + comparison the inline-`new` promotion already makes — the declared name when + the defining file was dispatched this run, its PSR-4 path when it was not. + + Two shapes carry no evidence either way and so keep the edge: + * a name with no namespace segment (`use Client;` imports from the global + namespace and writes nothing to compare); + * a path with fewer segments than the written name, once the declaration + is unavailable — composer maps a namespace PREFIX onto a directory + (`App\\Domain\\` -> `src/`), and a stripped prefix is indistinguishable + from a different class. Refusing there would delete true edges on + incremental rebuilds only, where the declaration is what is missing; + persisting it for unchanged files is #23. + """ + if type_node is None: + return False + want = [seg for seg in str(fqn).split("\\") if seg] + if len(want) < 2: + return False + if not declared_fqn: + parts = [ + part + for part in str(type_node.get("source_file") or "").replace("\\", "/").split("/") + if part and part not in (".", "..") + ] + if len(parts) < len(want): + return False + return not _php_qualified_corroborates(fqn, type_node, declared_fqn) + + +class PhpNameResolver: + """``use``-import/namespace-aware PHP receiver-type resolution (#21). + + The PHP twin of ``CsharpNameResolver`` (``extractors/csharp.py``), built for + the same reason: ``_resolve_php_member_calls`` bound a receiver's short type + name through a corpus-wide index whose only refusal rule was "more than one + candidate", so a file that writes ``use Vendor\\Sdk\\Client;`` — CLAIMING the + name ``Client`` for a class outside the corpus — still bound the lone + unrelated ``App\\Local\\Client`` and minted a wrong ``INFERRED 0.8`` edge + (#16). Consulted in front of that fallback, this resolver makes the claim + decisive: it refuses instead of guessing. + + Built from graph-stamped facts only — the ``imports`` edges' ``use`` + metadata (#19), the declared-FQN payload of the files dispatched this run + (#14), and the same type-definition index the fallback uses. Nothing is + re-parsed, and no new persisted marker is needed: the ``use`` map belongs to + the CALLING file, which an incremental rebuild always re-dispatches. + + STRICTLY SUBTRACTIVE by construction. Every node this returns is looked up + under the receiver's WRITTEN short name in the very index the fallback + consults, so a positive verdict is always the answer the fallback would have + given; the only behavior change is the refusal. Binding ``use App\\X as Y;`` + to a class the short name ``Y`` does not name, or picking the aliased one of + several same-short-named classes, is a recall ADDITION and belongs to #22. + """ + + def __init__( + self, + all_nodes: list[dict], + all_edges: list[dict], + type_def_nids: dict[str, list[str]], + class_fqn_by_file: dict[str, dict[str, str]] | None = None, + ) -> None: + self.type_def_nids = type_def_nids + self.class_fqn_by_file = class_fqn_by_file or {} + self.node_by_id: dict[str, dict] = { + node["id"]: node + for node in all_nodes + if isinstance(node.get("id"), str) and node.get("id") + } + + # Per file: claimed short name -> imported FQN. Read off the edge + # METADATA, never the target node's label — `_resolve_php_type_references` + # re-points import targets (onto an FQN stub, or onto the in-corpus class + # the unique-label rewire finds), and only the metadata still spells what + # the file actually wrote (#19). + self.uses_by_file: dict[str, dict[str, str]] = {} + for edge in all_edges: + if edge.get("relation") != "imports": + continue + source_file = edge.get("source_file") + if not _is_php_file(source_file): + continue + metadata = _metadata(edge.get("metadata")) + # `use function` / `use const` import no class name, in either + # spelling — the shared parser reports the declaration-level keyword + # of the group form too (#26). + if metadata.get("use_kind") != "class": + continue + target_fqn = metadata.get("target_fqn") + if not isinstance(target_fqn, str) or not target_fqn: + continue + alias = metadata.get("alias") + claimed = _php_key( + alias if isinstance(alias, str) and alias + else target_fqn.rsplit("\\", 1)[-1] + ) + if claimed: + # Two `use`s claiming one name is a PHP fatal error; first wins. + self.uses_by_file.setdefault(source_file, {}).setdefault( + claimed, target_fqn + ) + + # Per file: the namespace its declarations sit in, needed to resolve a + # namespace-RELATIVE annotation (`Local\Client`). Derived from the + # declared-FQN payload, which covers every file dispatched this run — + # and the file that writes the annotation always is one. A file that + # declares two namespaces (a PSR-1 violation) is left out rather than + # answered with one of them. + self.namespace_by_file: dict[str, str] = {} + for path, classes in self.class_fqn_by_file.items(): + namespaces = { + fqn.rsplit("\\", 1)[0] if "\\" in fqn else "" + for fqn in classes.values() + } + if len(namespaces) == 1: + self.namespace_by_file[path] = next(iter(namespaces)) + + def _declared_fqn(self, type_node: dict | None) -> str | None: + """The name the DEFINING file declares for ``type_node``'s class (#14). + + Absent for a global-namespace class, and for a class whose file was not + dispatched this run — an incremental rebuild then falls back to the + PSR-4 path comparison, which is why the refusal needs no new marker. + """ + if not type_node: + return None + by_name = self.class_fqn_by_file.get(str(type_node.get("source_file") or "")) + if not by_name: + return None + return by_name.get(_php_key(type_node.get("label", ""))) + + def _written_fqn(self, written: str, source_file: str) -> str | None: + """The FQN a QUALIFIED written annotation denotes, or None if unknowable. + + PHP resolves `\\A\\B` absolutely, `A\\B` through the `use` map's + group-prefix semantics and otherwise relative to the current namespace — + never against the global namespace. A leading backslash is therefore not + something to assume nor to strip blindly (#20). + """ + raw = written.strip() + if raw.startswith("\\"): + return raw.lstrip("\\") + uses = self.uses_by_file.get(source_file, {}) + if _php_key(raw.split("\\", 1)[0]) in uses: + return _php_fqn_from_raw(raw, "", uses) + namespace = self.namespace_by_file.get(source_file) + if namespace is not None: + return _php_fqn_from_raw(raw, namespace, uses) + if source_file in self.class_fqn_by_file: + return None # two namespaces in one file: refuse to pick one + return _php_fqn_from_raw(raw, "", uses) # the file is global-namespace + + def resolve_type_name( + self, type_name: str, qualified: object, source_file: str + ) -> tuple[str | None, bool]: + """Resolve a receiver's declared type to a definition node, with a verdict. + + Returns ``(node_id, decisive)``: + * ``(nid, True)`` — the file's own naming lands on that definition. + * ``(None, True)`` — the file CLAIMS the name (a `use` import, or a + qualified form written at the annotation) and the claim does not + land on an in-corpus class: refuse, and do NOT let the caller fall + back to the looser corpus-wide bare-name match. This is #16. + * ``(None, False)`` — nothing in the file claims the name; the + caller's existing fallback runs unchanged. + """ + short = _php_key(type_name) + if not short: + return None, False + written = qualified.strip() if isinstance(qualified, str) else "" + if "\\" in written: + fqn = self._written_fqn(written, source_file) + else: + fqn = self.uses_by_file.get(source_file, {}).get(short) + if not fqn: + return None, False + + candidates = self.type_def_nids.get(short, []) + if len(candidates) != 1: + # Nothing in the corpus answers to the written name, or several + # things do and the fallback would refuse too. Either way there is + # no edge to keep — telling the alias's target apart from its + # namesakes is #22's job, and this ticket may only delete. + return None, True + node = self.node_by_id.get(candidates[0]) + if _php_fqn_names_another_class(fqn, node, self._declared_fqn(node)): + return None, True + return candidates[0], True diff --git a/tests/test_php_name_resolver.py b/tests/test_php_name_resolver.py new file mode 100644 index 0000000000..a39690dbcc --- /dev/null +++ b/tests/test_php_name_resolver.py @@ -0,0 +1,393 @@ +"""PHP `use`-import-aware receiver typing: the decisive refusal (#21, closes #16). + +`_resolve_php_member_calls` used to bind a receiver's short type name through a +corpus-wide index whose only refusal rule was "more than one candidate". A file +that writes `use Vendor\\Sdk\\Client;` has CLAIMED the name `Client` for a class +that is not in the corpus at all — but the resolver never saw `use` statements, +so the lone unrelated `App\\Local\\Client` satisfied the single-definition guard +and minted a wrong `INFERRED 0.8` edge. + +`PhpNameResolver` (mirroring `CsharpNameResolver`) answers with a +`(node_id, decisive)` verdict and is consulted IN FRONT of that fallback: a +claimed name that does not land on an in-corpus class refuses instead of falling +back. The pass is strictly SUBTRACTIVE — every verdict it returns positively is +one the fallback would have returned anyway, so it can only delete edges. +Binding an alias to the right one of several same-short-named classes is a +recall ADDITION and belongs to #22, pinned below by +`test_alias_renaming_to_an_unclaimed_short_name_stays_unresolved`. + +Every test goes through the public `extract()` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# The canonical #16 fixture (spec #18 carries the same copy) plus a decoy that +# declares an identically named method: only the receiver's type tells them +# apart, so a bare-name match would light the decoy up. +_CLIENT = ( + " str: + return ( + "c->send(); }\n" + "}\n" + ) + + +def _sends(calls, caller: str) -> list[str]: + return [tgt for src, tgt in calls if src == caller] + + +# ── the refusal: a claimed name that names no in-corpus class ───────────────── + + +def test_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """#16, cross-file: `use Vendor\\Sdk\\Client;` claims `Client` for a class + this corpus does not contain. The lone `App\\Local\\Client` is a stranger.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], "an out-of-corpus `use` target must refuse" + + +def test_use_alias_outside_corpus_emits_no_edge_same_file(tmp_path: Path): + """#16, same file: the stranger is declared in another namespace block of + the calling file, so the same-file matcher could mint the edge too.""" + calls, r = _calls(tmp_path, { + "app/Mixed/Both.php": ( + "c->send(); }\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], \ + "a same-file stranger must be refused like a cross-file one" + + +def test_group_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """The group form carries its FQN on the declaration's prefix (#19); the + claim it makes is the same one.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\{Client};\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_renaming_alias_over_an_out_of_corpus_target_emits_no_edge(tmp_path: Path): + """`use X as Client;` claims the short name `Client` just as firmly.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Handler as Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_written_fqn_outside_corpus_emits_no_edge(tmp_path: Path): + """The compounding half of #16: an annotation that names the out-of-corpus + class outright, with no `use` statement at all (#20 kept the written form).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\Vendor\\Sdk\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_namespace_relative_annotation_outside_corpus_emits_no_edge(tmp_path: Path): + """A written qualified name with no leading backslash is RELATIVE: inside + `App\\Http`, `Local\\Client` means `App\\Http\\Local\\Client`, which exists + nowhere — not `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the guards: everything the refusal must NOT touch ───────────────────────── + + +def test_in_corpus_use_alias_keeps_its_edge(tmp_path: Path): + """The positive guard: a `use` that names the real in-corpus class binds + exactly as it does today (through the unique-short-name fallback), and the + decoy that merely shares the method name still gets nothing.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + send = _find(r, ".send()", "client") + assert (go, send) in calls + assert calls[(go, send)]["confidence"] == "INFERRED" + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_written_fqn_naming_the_in_corpus_class_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\App\\Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_namespace_relative_annotation_in_corpus_keeps_its_edge(tmp_path: Path): + """Inside namespace `App`, `Local\\Client` IS `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/I.php": _caller(annotation="Local\\Client", namespace="App"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_use_function_import_makes_no_class_claim(tmp_path: Path): + """`use function` / `use const` import no class name (#26), in either + spelling — so they claim nothing and the fallback runs untouched.""" + for index, uses in enumerate(( + "use function Vendor\\Sdk\\Client;\n", + "use function Vendor\\Sdk\\{Client};\n", + "use const Vendor\\Sdk\\Client;\n", + )): + calls, r = _calls(tmp_path / f"case{index}", { + **_CORPUS, "app/Http/I.php": _caller(uses), + }) + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls, uses + assert (go, _find(r, ".send()", "recorder")) not in calls, uses + + +def test_unclaimed_short_name_still_falls_back(tmp_path: Path): + """No `use`, no qualified form: the resolver knows nothing about the name + and the corpus-wide fallback decides, exactly as before.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_alias_renaming_to_an_unclaimed_short_name_stays_unresolved(tmp_path: Path): + """The #21/#22 boundary. `use App\\Local\\Client as Api;` names an in-corpus + class, but the WRITTEN short name is `Api` and nothing in the corpus is + called that, so today's fallback finds nothing and this ticket adds no edge: + binding the alias to `App\\Local\\Client` is #22's recall win.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client as Api;\n", annotation="Api"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the same verdicts across an incremental rebuild ─────────────────────────── +# +# The `use` map belongs to the CALLING file, which `graphify update`/watch always +# re-dispatch, so the refusal needs no persisted marker (spec #18, decision 3). +# The context below is assembled exactly as `test_php_member_calls.py` mirrors +# watch.py: a field subset of the persisted nodes plus contains/method edges, +# both scoped to the files that are NOT re-extracted. + +_CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_non_class_types", + "_php_interfaces") + + +def _watch_resolution_context(result: dict, unchanged: set[str]): + nodes = [] + for node in result["nodes"]: + if not node.get("id") or node.get("source_file") not in unchanged: + continue + ctx = {"id": node["id"]} + ctx.update({field: node.get(field) for field in _CTX_NODE_FIELDS}) + ctx.update({m: node[m] for m in _CTX_MARKERS if node.get(m)}) + nodes.append(ctx) + edges = [ + { + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": edge.get("source_file"), + } + for edge in result["edges"] + if edge.get("relation") in ("contains", "method") + and edge.get("source_file") in unchanged + ] + return nodes, edges + + +def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed} + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_CALLER = "app/Http/I.php" + + +def test_alias_refusal_survives_incremental_rebuild(tmp_path: Path): + """`App\\Local\\Client` is unchanged and therefore not dispatched; the claim + lives in the caller, which is, so the refusal must fire either way.""" + (full_calls, _), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use Vendor\\Sdk\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + assert _sends(full_calls, go) == [], "full-build baseline must refuse" + assert _sends(inc_calls, go) == [], \ + "an undispatched defining file must not resurrect the false edge" + + +def test_in_corpus_alias_still_resolves_incrementally(tmp_path: Path): + """Positive control for the test above: the defining file's declared FQN is + unavailable on an incremental run, so the class is corroborated against its + PSR-4 path instead — and the edge survives, decoy still empty.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use App\\Local\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + # The target lives in an unchanged file, so its id comes from the full run. + send = _find(full, ".send()", "client") + assert (go, send) in full_calls + assert (go, send) in inc_calls, \ + "a rebuild must keep an edge whose `use` names the in-corpus class" + assert (go, _find(full, ".send()", "recorder")) not in inc_calls + + +def test_non_psr4_layout_keeps_its_edge_incrementally(tmp_path: Path): + """Composer maps a namespace PREFIX onto a directory (`App\\Weird\\` -> + `src/`), so `App\\Weird\\Odd` legitimately lives at `src/Odd.php`. The full + run corroborates the `use` against the namespace that file DECLARES; the + incremental run no longer has the declaration and must not read the shorter + path as a contradiction — a stripped prefix looks exactly like one.""" + caller = ( + "o->ping(); }\n" + "}\n" + ) + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "src/Odd.php": ( + " Date: Thu, 6 Aug 2026 10:11:20 -0300 Subject: [PATCH 21/41] fix(extract): scope the last four receiver-type indexes to their own sources (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C++, Swift, TypeScript and Python member-call resolvers built their receiver-type index from every type-like node in the corpus, so a receiver's declared type name was matched against class definitions written in ANY language. #8 (PHP/ObjC) and #10 (Java/C#) closed the same defect on the other six; these were the copies left over. The defect cuts both ways, so it is two fixes per resolver: * a foreign class TYPES the receiver, minting a cross-language edge that should not exist — `Lead lead; lead.search()` in C++ bound to a Python `class Lead` at INFERRED; * a foreign class merely SHARING the short name pushes the single-definition god-node guard to 2 and silently suppresses the correct same-language edge. Python needed more than a copy-paste on both arms. Its class index is built from the SOURCES of `method` edges rather than from a scan over `all_nodes`, so the suffix test goes on the looked-up owner node; and its `module.func()` arm matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound the call to a TypeScript function at EXTRACTED. C++ had no suffix tuple yet. The new `_CPP_RESOLVER_SUFFIXES` includes `.h`, because a C++ class is routinely declared in a header — `.h` routes to extract_cpp or extract_objc by content, so it sits in both the C++ and the ObjC definition-index sets. That is the accepted compromise: the two are isolated from every other language but not from each other, which no suffix can fix. Raw-call ownership for C++/ObjC is unaffected and stays on the extractor- stamped `lang`, for exactly that reason. The tuple also now feeds the `cpp_member_calls` registration, so registration and scoping cannot drift. All ten indexes now go through one `_is_owned_definition` predicate, the definition-index twin of `_raw_call_is_owned`. Tests: ten new cases in tests/test_mixed_corpus_member_calls.py, both defect directions for C++, Swift, TypeScript and the Python class arm, plus the Python module arm's leak direction and its positive control. Nine were red before this change; the tenth is the module-arm positive control. The module arm's suppression direction is NOT covered here — two same-stem files salt the FILE node ids apart while the `imports` edge target stays the bare alias, so the arm sees zero candidates rather than an ambiguous two. That is a separate defect one layer down, tracked as #33. The existing ObjC defect-1 case is widened to assert through `_cross_language_targets`, so a leak landing as a `references` edge onto the foreign TYPE (rather than a `calls` edge onto its method) is caught too. It was already green — #8 scoped that index — and stands as a regression guard. Suite: 4097 passed / 36 skipped, up from 4087 / 36 by exactly the ten new tests. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +- graphify/extract.py | 69 +++++-- tests/test_mixed_corpus_member_calls.py | 254 +++++++++++++++++++++++- 3 files changed, 301 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f0c128e0..26b4121170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu - 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 Java and C# halves are fixed by the next bullet, and the same exposure in the C++, Swift, TypeScript and Python resolvers is untouched and left as a follow-up (`lawnstarter/graphify#24`). -- Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is tracked as `lawnstarter/graphify#24`. +- 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 Java and C# halves are fixed by the next bullet, and the C++, Swift, TypeScript and Python halves by the one after it. +- Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is closed by the next bullet. +- Fix: the last four receiver-type indexes — C++, Swift, TypeScript and Python — are now scoped to their own sources too, so no member-call resolver matches a receiver's declared type against class definitions written in another language any more (`lawnstarter/graphify#24`). This is the same two-way defect the PHP/ObjC and Java/C# bullets above describe, in its remaining copies: a C++ `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED, a Swift `let lead: Lead` and a TypeScript `private lead: Lead` did the same, and — in the other direction — 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 LOSE these cross-language edges, which were always wrong, and GAIN C++, Swift, TypeScript and Python edges that a foreign short-name collision previously deleted. Single-language corpora are unaffected: the filter admits everything such a corpus contains. Two notes on the edges of this change. `.h` is scoped into BOTH the C++ and the Objective-C index, because it routes to either extractor by content and a C++ class and an ObjC `@interface` both live in a header — the two languages are therefore isolated from every other language but not from each other, which no suffix can fix, and raw-call ownership for them stays on the extractor-stamped `lang` for exactly that reason. And Python is scoped on both of its arms, not just the class index: its `module.func()` arm matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound the call to a TypeScript function at EXTRACTED. All ten resolvers now share one `_is_owned_definition` predicate keyed off the same per-resolver suffix tuple that registers the resolver, so the registration and the scoping cannot drift. Pre-existing, not a regression. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. diff --git a/graphify/extract.py b/graphify/extract.py index 5531408e78..ff91fbbf4d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2281,8 +2281,13 @@ def _assembly_of_node(nid: str) -> str: ) # `.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. +# `lang`, never by suffix; only the DEFINITION index is scoped by suffix, where +# including `.h` is correct — a C++ class and an ObjC @interface both live in +# one. The consequence is that C++ and ObjC are isolated from every other +# language but not from each other, which no suffix can fix (#24). +_CPP_RESOLVER_SUFFIXES = ( + ".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h", +) _OBJC_RESOLVER_SUFFIXES = (".m", ".mm", ".h") @@ -2298,6 +2303,17 @@ def _raw_call_is_owned(rc: dict, suffixes: tuple[str, ...]) -> bool: return str(rc.get("source_file") or "").lower().endswith(suffixes) +def _is_owned_definition(node: "dict | None", suffixes: tuple[str, ...]) -> bool: + """True when ``node`` was declared in a source file with one of ``suffixes``. + + The definition-index twin of ``_raw_call_is_owned`` (#8, #10, #24). Every + member-call resolver scopes its receiver-type index through this, so a + receiver's declared type can only ever bind to a type written in the same + language. + """ + return str((node or {}).get("source_file") or "").lower().endswith(suffixes) + + def _resolve_swift_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -2336,12 +2352,17 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} # Type name -> definition node ids (real, source-backed, type-like defs only). - # len != 1 is the god-node guard: an ambiguous type name bails. + # len != 1 is the god-node guard: an ambiguous type name bails. Scoped to + # Swift sources (#24): unscoped, a Python `class Lead` could type + # `let lead: Lead` and mint a cross-language edge, and a foreign class merely + # SHARING the short name pushed the guard to 2 and deleted the correct Swift + # 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): + if (_is_owned_definition(n, _SWIFT_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"]) # (type_node_id, method_key) -> method_node_id, from `method` edges. @@ -2447,7 +2468,11 @@ def _key(label: str) -> str: # A class owns methods: it is the source of one or more `method` edges. Index # class label -> owning class node ids (len != 1 is the god-node guard), and - # (class_node_id, method_key) -> method_node_id. + # (class_node_id, method_key) -> method_node_id. Only classes declared in + # Python sources are candidates (#24): unscoped, `Lead.search()` bound to a + # Java `class Lead` at EXTRACTED, and a foreign class merely SHARING the name + # pushed the guard to 2 and deleted the correct Python edge. `method_index` + # needs no scoping — it is only ever keyed by a class id already admitted here. class_def_nids: dict[str, list[str]] = {} method_index: dict[tuple[str, str], str] = {} for e in all_edges: @@ -2455,7 +2480,7 @@ def _key(label: str) -> str: continue src, tgt = e.get("source"), e.get("target") cnode = node_by_id.get(src) - if cnode is not None: + if cnode is not None and _is_owned_definition(cnode, _PYTHON_RESOLVER_SUFFIXES): class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(src) tnode = node_by_id.get(tgt) if tnode is not None: @@ -2555,11 +2580,16 @@ def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None: # never match), then to the single callable that module contains. A # receiver also matches the local alias bound on that import edge # (#2082), so an aliased import resolves the same as the bare name. + # A candidate module must itself be a Python file (#24): matching on + # the stem alone, a `lead.ts` answered `import lead` and bound the + # call to a TypeScript function, and a `lead.ts` sitting beside the + # real `lead.py` made the pair ambiguous and deleted the true edge. rkey = _key(receiver) caller_file = file_of_node.get(caller) file_aliases = import_alias_by_filenode.get(caller_file, {}) mods = [t for t in imported_by_filenode.get(caller_file, ()) if t in contains_children + and _is_owned_definition(node_by_id.get(t), _PYTHON_RESOLVER_SUFFIXES) and (_module_stem_key(t) == rkey or file_aliases.get(t) == rkey)] if len(mods) != 1: # not an imported module, or ambiguous -> bail continue @@ -2596,11 +2626,16 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to TS/JS sources (#24): unscoped, a Python `class Lead` could type + # `private lead: Lead` and mint a cross-language edge, and a foreign class + # merely SHARING the short name pushed the single-definition guard to 2 and + # deleted the correct TypeScript 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): + if (_is_owned_definition(n, _TYPESCRIPT_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] = {} @@ -2709,11 +2744,17 @@ def _key(label: str) -> str: # excluding non-contained nodes keeps them from making a real type ambiguous. contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to C++ sources (#24): unscoped, a Python `class Lead` could type + # `Lead lead;` and mint a cross-language edge, and a foreign class merely + # SHARING the short name pushed the single-definition guard to 2 and deleted + # the correct C++ edge. `.h` is in the set (and in ObjC's), so the two stay + # distinguishable from every other language but not from each other. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _CPP_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"]) # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type @@ -2848,8 +2889,7 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - sf = str(n.get("source_file") or "").lower() - if (sf.endswith(_CSHARP_RESOLVER_SUFFIXES) + if (_is_owned_definition(n, _CSHARP_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"]) @@ -3037,7 +3077,7 @@ def key(label: str) -> str: type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - str(node.get("source_file") or "").lower().endswith(_JAVA_RESOLVER_SUFFIXES) + _is_owned_definition(node, _JAVA_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -3172,7 +3212,7 @@ def key(label: str) -> str: type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - str(node.get("source_file") or "").lower().endswith(_PHP_RESOLVER_SUFFIXES) + _is_owned_definition(node, _PHP_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -3366,8 +3406,7 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - sf = str(n.get("source_file") or "").lower() - if (sf.endswith(_OBJC_RESOLVER_SUFFIXES) + if (_is_owned_definition(n, _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"]) @@ -3475,7 +3514,7 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver( "cpp_member_calls", - frozenset({".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h"}), + frozenset(_CPP_RESOLVER_SUFFIXES), _resolve_cpp_member_calls, ) ) diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py index 33449b0f74..5171c1b5d9 100644 --- a/tests/test_mixed_corpus_member_calls.py +++ b/tests/test_mixed_corpus_member_calls.py @@ -1,4 +1,4 @@ -"""Mixed-corpus isolation for the member-call resolvers (#6, #8, #10, spec #1682). +"""Mixed-corpus isolation for the member-call resolvers (#6, #8, #10, #24, spec #1682). A corpus that mixes languages must not let one language's raw call data mint an edge through a different language's member-call resolver. Two independent @@ -10,7 +10,7 @@ tag, so those three filter by source-file suffix instead (#10). * **Definition-index scoping** -- the receiver-type index a resolver builds holds only types declared in its own sources (#8 for PHP/ObjC, #10 for - Java/C#). + Java/C#, #24 for C++, Swift, TypeScript and Python). 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, @@ -23,7 +23,7 @@ from graphify.extract import extract -def _calls(tmp_path: Path, files: dict[str, str]): +def _calls(tmp_path: Path, files: dict[str, str], cache_root: Path | None = None): """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" paths = [] for name, body in files.items(): @@ -31,7 +31,7 @@ def _calls(tmp_path: Path, files: dict[str, str]): 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") + result = extract(paths, cache_root=cache_root or (tmp_path / "graphify-out")) calls = { (edge["source"], edge["target"]): edge for edge in result["edges"] @@ -64,8 +64,27 @@ def _call_context_pairs(result: dict) -> set[tuple[str, str]]: } -# A Python class whose method name collides with the PHP call's callee. Nothing -# in a PHP file may ever bind to it. +def _cross_language_targets(result: dict, caller: str, file_suffix: str) -> set[str]: + """Call-context targets of ``caller`` that live in ``file_suffix``. + + Wider than naming one node: an index leak surfaces as ``calls`` onto the + foreign METHOD, or -- when the callee name misses on the foreign type -- as a + ``references`` edge onto the foreign TYPE itself. Both are the same bug. + """ + foreign = { + node["id"] + for node in result["nodes"] + if str(node.get("source_file") or "").endswith(file_suffix) + } + return { + target + for source, target in _call_context_pairs(result) + if source == caller and target in foreign + } + + +# A Python class whose method name collides with the other languages' callee. +# Nothing written in another language may ever bind to it. _PY_DECOY = ( "class Lead:\n" " def search(self, filters):\n" @@ -202,8 +221,13 @@ def test_php_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path) 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, { + """Defect 1, ObjC twin: `[Lead search]` with no ObjC `Lead` in the corpus. + + Asserted through ``_cross_language_targets`` rather than on one named node, + so a leak that lands as a ``references`` edge onto the Python CLASS (instead + of a ``calls`` edge onto its method) is caught too. + """ + _, result = _calls(tmp_path, { "svc.py": _PY_DECOY, "src/Runner.m": ( "@implementation Runner\n" @@ -213,8 +237,7 @@ def test_objc_receiver_type_does_not_match_a_python_class(tmp_path: Path): }) go = _nid(result, "-go", "Runner.m") - py_search = _nid(result, ".search()", "svc.py") - assert (go, py_search) not in calls, \ + assert not _cross_language_targets(result, go, "svc.py"), \ "an ObjC receiver type must not resolve against a Python class" @@ -463,3 +486,214 @@ def test_csharp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Pa assert (go, cs_search) in calls, \ "a same-named class in another language suppressed the real C# edge" assert (go, py_search) not in calls, "the Python decoy received an edge" + + +# ── Language-scoped C++, Swift and TypeScript receiver type indexes (#24) ───── +# +# The remaining copies of the same shape. Each of these three resolvers built +# `type_def_nids` from every type-like node in the corpus, so the receiver's +# declared type name was matched against class definitions written in ANY +# language. Both directions of the defect are covered per language: the foreign +# class TYPING the receiver, and the foreign class merely SHARING the short name +# pushing the single-definition guard to 2 and deleting the correct edge. +# +# Raw-call ownership (above) cannot close this: the raw call being resolved is +# genuinely the resolver's own, and the leak is in what its INDEX offers up. +# +# ObjC is not repeated here -- #8 scoped its index, and the two ObjC cases above +# already guard both directions. + +_CPP_CALLER = ( + "class Runner {\n" + "public:\n" + " void go() { Lead lead; lead.search(); }\n" + "};\n" +) +"""`Lead lead;` is a local declaration, so the C++ `cpp_type_table` types the +receiver and the call resolves at INFERRED.""" + +_SWIFT_CALLER = ( + "class Runner {\n" + " let lead: Lead\n" + " func go() { lead.search() }\n" + "}\n" +) +"""Declared without an initializer on purpose: `= Lead()` would additionally be +picked up by the shared cross-file CALL pass, which is a different mechanism +and would muddy what this test pins down.""" + +_TS_CALLER = ( + "export class Runner {\n" + " constructor(private lead: Lead) {}\n" + " go() { return this.lead.search(); }\n" + "}\n" +) + + +def test_cpp_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No C++ `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "Runner.cpp": _CPP_CALLER}) + + go = _nid(result, ".go()", "Runner.cpp") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a C++ receiver type must not resolve against a Python class" + + +def test_cpp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real C++ edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "lead.cpp": "class Lead {\npublic:\n void search() {}\n};\n", + "Runner.cpp": _CPP_CALLER, + }) + + go = _nid(result, ".go()", "Runner.cpp") + cpp_search = _nid(result, ".search()", "lead.cpp") + py_search = _nid(result, ".search()", "svc.py") + assert (go, cpp_search) in calls, \ + "a same-named Python class suppressed the real C++ edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, cpp_search)]["confidence"] == "INFERRED" + + +def test_swift_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No Swift `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "Runner.swift": _SWIFT_CALLER}) + + go = _nid(result, ".go()", "Runner.swift") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a Swift receiver type must not resolve against a Python class" + + +def test_swift_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real Swift edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Lead.swift": "class Lead { func search() {} }\n", + "Runner.swift": _SWIFT_CALLER, + }) + + go = _nid(result, ".go()", "Runner.swift") + swift_search = _nid(result, ".search()", "Lead.swift") + py_search = _nid(result, ".search()", "svc.py") + assert (go, swift_search) in calls, \ + "a same-named Python class suppressed the real Swift edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, swift_search)]["confidence"] == "INFERRED" + + +def test_typescript_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No TypeScript `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "runner.ts": _TS_CALLER}) + + go = _nid(result, ".go()", "runner.ts") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a TypeScript receiver type must not resolve against a Python class" + + +def test_typescript_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real TypeScript edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "lead.ts": "export class Lead { search() { return []; } }\n", + "runner.ts": _TS_CALLER, + }) + + go = _nid(result, ".go()", "runner.ts") + ts_search = _nid(result, ".search()", "lead.ts") + py_search = _nid(result, ".search()", "svc.py") + assert (go, ts_search) in calls, \ + "a same-named Python class suppressed the real TypeScript edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, ts_search)]["confidence"] == "EXTRACTED" + + +# ── Language-scoped Python class and module indexes (#24) ──────────────────── +# +# The Python resolver's two arms are indexed differently from every resolver +# above -- its class index is built by walking `method` edges rather than by +# filtering source-backed nodes, and its module arm resolves through the +# caller's own `imports` edges -- but both were corpus-wide all the same. +# +# The class arm is covered in both directions. The module arm is covered only +# for the leak: its suppression direction is unreachable, because two same-stem +# files disambiguate the FILE node ids (`lead_py_lead`, `lead_ts_lead`) while +# the `imports` edge target stays the bare alias `lead`, so the arm sees zero +# candidates rather than an ambiguous two. That is a separate defect in the +# import-alias remap, tracked as #33 and untouched here. + +_JAVA_DECOY = "class Lead { void search() {} }\n" + + +def test_python_class_receiver_does_not_match_a_java_class(tmp_path: Path): + """Class arm, defect 1: no Python `Lead` exists, only a Java one. + + `Lead.search()` is a Python raw call the Python resolver rightly owns. Its + class index held every class in the corpus, so the Java `Lead` answered for + the receiver and the call was minted at EXTRACTED -- the label reserved for + an explicitly named, unambiguous class reference. + """ + _, result = _calls(tmp_path, { + "caller.py": "def run():\n Lead.search()\n", + "Lead.java": _JAVA_DECOY, + }) + + run = _nid(result, "run()", "caller.py") + assert not _cross_language_targets(result, run, "Lead.java"), \ + "a Python class receiver must not resolve against a Java class" + + +def test_python_class_receiver_resolves_despite_a_same_named_java_class(tmp_path: Path): + """Class arm, defect 2: the Java decoy must not delete the Python edge.""" + calls, result = _calls(tmp_path, { + "caller.py": "from svc import Lead\n\n\ndef run():\n Lead.search()\n", + "svc.py": _PY_DECOY, + "Lead.java": _JAVA_DECOY, + }) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, ".search()", "svc.py") + java_search = _nid(result, ".search()", "Lead.java") + assert (run, py_search) in calls, \ + "a same-named Java class suppressed the real Python edge" + assert (run, java_search) not in calls, "the Java decoy received an edge" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +# The module arm resolves an `import` to the imported file's node, which only +# lines up when ids are relativized against the corpus root itself -- hence the +# explicit `cache_root`, matching the prior art in +# `tests/test_extract.py::test_python_module_qualified_call_resolves_extracted`. +_MOD_CALLER = "import lead\n\n\ndef run():\n lead.search()\n" +_TS_MODULE_DECOY = "export function search() { return []; }\n" + + +def test_python_module_receiver_does_not_match_a_typescript_file(tmp_path: Path): + """Module arm, defect 1: `import lead` must not reach `lead.ts`. + + No `lead.py` exists. The arm matched any corpus file whose stem equalled the + receiver, so a TypeScript file of the same stem satisfied it and + `lead.search()` bound to a TypeScript function at EXTRACTED -- an import + edge Python could not possibly have. + """ + _, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + assert not _cross_language_targets(result, run, "lead.ts"), \ + "a Python module receiver must not resolve against a TypeScript file" + + +def test_python_module_receiver_still_resolves_its_own_module(tmp_path: Path): + """The module arm's positive control: scoping must not cost the real edge.""" + calls, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, "search()", "lead.py") + assert (run, py_search) in calls, "the module arm stopped resolving" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" From 10cdee2583631083b3a47d7f90338d49ff033e4f Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 10:16:49 -0300 Subject: [PATCH 22/41] fix(extract): keep stem-named import edges alive across a same-stem sibling (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash all name an import's target by the imported file's bare stem id (`import lead` -> `lead`). That resolves only while the id is unique: add ANY same-stem file — a `lead.md` will do — and the two file nodes collide, so `_disambiguate_colliding_node_ids` salts them apart into `lead_py_lead` and `lead_md_lead`. The edge's target salt is keyed by the IMPORTER's source_file, which matches neither, so the edge was left pointing at an id that no longer named anything: silently dropped, along with everything downstream of it — the Python resolver's `module.func()` arm among them, which is the loose end #24 recorded and deferred here. `_hint_import_targets` stamps the `target_file` hint the disambiguator already accepts for this shape (#1814), keying the target salt by that file instead. Stamped centrally off one `_IMPORT_STEM_LANGUAGES` table rather than in each extractor, because an extractor sees one file and cannot know which of the corpus's same-stem candidates the id will end up naming. Bash needed a second fix. Its `resolve_bash_source_edges` pass runs AFTER disambiguation but re-derives file ids from the path formula, so under a collision it emitted the edge twice — once correct, once dangling — and its source-backed `calls` edges named nodes that did not exist. It now receives the ids as they actually stand, via `_file_nids_by_path`; the formula remains the fallback for paths the caller did not resolve and for direct callers that pass nothing, so existing behavior is untouched. Audited and NOT vulnerable, so untouched: TS/JS and C/C++/ObjC already stamp their own equivalent protection; Julia, Fortran and Verilog target an importer-scoped node; Dart mints its own stub nodes; Ruby/PHP emit no file-targeting import edges. Tests: ten new cases. tests/test_import_alias_disambiguation.py parametrizes control-vs-collided corpora over the six non-Python languages (seven cases — PowerShell has two import spellings), each asserting BOTH directions so an inert fixture cannot pass by accident, plus one case pinning that the transient `target_file` hint never reaches the graph. tests/test_mixed_corpus_member_calls .py adds the Python pair: the `module.func()` call edge and the `imports` edge one layer under it, asserted separately because the module arm is only one consumer. Nine were red before this change; the hint-leak case passed vacuously beforehand (nothing stamped the hint yet) and is a real guard only now. Parity evidence for the no-collision case, on a 14-file corpus covering all seven languages with every stem unique: the serialized node+edge set is bit-identical across this change, md5 4c6281a76937fe051d641673f486c89a both sides. Adding a same-stem `.md` beside each of the seven targets goes from 8 import edges / 7 dangling to 7 / 0 — the extra edge before being exactly the Bash duplicate. Suite: 4107 passed / 36 skipped, up from 4097 / 36 by exactly the ten new tests. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extract.py | 136 ++++++++++++++++++++- graphify/symbol_resolution.py | 13 +- tests/test_import_alias_disambiguation.py | 141 ++++++++++++++++++++++ tests/test_mixed_corpus_member_calls.py | 67 +++++++++- 5 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 tests/test_import_alias_disambiguation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b4121170..8f1dd9f111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the Java and C# halves are fixed by the next bullet, and the C++, Swift, TypeScript and Python halves by the one after it. - Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is closed by the next bullet. - Fix: the last four receiver-type indexes — C++, Swift, TypeScript and Python — are now scoped to their own sources too, so no member-call resolver matches a receiver's declared type against class definitions written in another language any more (`lawnstarter/graphify#24`). This is the same two-way defect the PHP/ObjC and Java/C# bullets above describe, in its remaining copies: a C++ `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED, a Swift `let lead: Lead` and a TypeScript `private lead: Lead` did the same, and — in the other direction — 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 LOSE these cross-language edges, which were always wrong, and GAIN C++, Swift, TypeScript and Python edges that a foreign short-name collision previously deleted. Single-language corpora are unaffected: the filter admits everything such a corpus contains. Two notes on the edges of this change. `.h` is scoped into BOTH the C++ and the Objective-C index, because it routes to either extractor by content and a C++ class and an ObjC `@interface` both live in a header — the two languages are therefore isolated from every other language but not from each other, which no suffix can fix, and raw-call ownership for them stays on the extractor-stamped `lang` for exactly that reason. And Python is scoped on both of its arms, not just the class index: its `module.func()` arm matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound the call to a TypeScript function at EXTRACTED. All ten resolvers now share one `_is_owned_definition` predicate keyed off the same per-resolver suffix tuple that registers the resolver, so the registration and the scoping cannot drift. Pre-existing, not a regression. +- Fix: an `imports` edge no longer vanishes when any same-stem file sits beside its target, in Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash (`lawnstarter/graphify#33`). Each of these names an import's target by the imported file's bare stem id (`import lead` -> `lead`), which resolves only while that id is unique: add a `lead.md` and the two file nodes collide, so id-disambiguation salts them into `lead_py_lead` and `lead_md_lead` while the edge — keyed by the importer's own file rather than the target's — was left pointing at an id that no longer named anything, and was dropped along with everything downstream of it (Python's `module.func()` call resolution among them). These edges now stamp the `target_file` hint the disambiguator already accepts for this (#1814), so the salt lands on the right file; an import written in one language can only mean a file of that language, so the choice stays unambiguous whatever the collider is. The hint is stamped centrally rather than in each extractor, because an extractor sees one file and cannot know which of the corpus's same-stem candidates the id will end up naming; it is transient and popped by its only reader, so it never reaches `graph.json`. An id claimed by more than one importable file of the same language is left dangling, as before, and a corpus with no collision is bit-identical — the hint only ever selects among the salted variants of an id the edge already named, so it cannot change WHICH node an edge resolves to. Bash additionally emitted the edge twice under a collision — once correct, once dangling — because its second producer in `resolve_bash_source_edges` derives ids from the path after disambiguation has already renamed them; that pass now reads the ids as they actually stand, which also repairs the source-backed `calls` edges it resolves. TypeScript/JavaScript and C/C++/Objective-C already had equivalent protection; Julia, Fortran and Verilog target an importer-scoped node and were never exposed; Dart mints its own stub nodes and Ruby/PHP emit no file-targeting import edges. Pre-existing, not a regression. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. diff --git a/graphify/extract.py b/graphify/extract.py index ff91fbbf4d..b359bfc0e3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -262,6 +262,132 @@ def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None: e["target"] = alias_map[tgt] +# Languages whose import edges name their target by the imported file's bare +# stem id, as (importer suffixes, importable target suffixes). Each pair is +# closed within one language: an import written in one of the first set can only +# ever mean a file from the second, which is what makes the hint below +# unambiguous even when the colliding sibling belongs to another language (#33). +# +# Python's target set omits `.pyi` on purpose — it has no extractor, so it mints +# no file node to point a hint at, and listing it could only mask a real `.py` +# target behind a phantom ambiguity. +_IMPORT_STEM_LANGUAGES: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = ( + ((".py", ".pyi"), (".py",)), + ((".rs",), (".rs",)), + ((".zig",), (".zig",)), + ((".ex", ".exs"), (".ex", ".exs")), + ((".ps1", ".psm1", ".psd1"), (".ps1", ".psm1", ".psd1")), + ((".sh", ".bash"), (".sh", ".bash")), + ( + (".pas", ".pp", ".dpr", ".dpk", ".inc"), + (".pas", ".pp", ".dpr", ".dpk", ".inc"), + ), +) + + +def _file_nids_by_path(all_nodes, all_edges, root) -> dict: + """Resolved source path -> the id its file node carries RIGHT NOW. + + For passes that run after ``_disambiguate_colliding_node_ids`` and therefore + cannot derive a file's id from its path: a same-stem sibling salts the id + away from whatever the path formula would produce. A file node is the one + that ``contains`` its file's other nodes; an empty file contains nothing, so + fall back to the node whose label is the file's own basename. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + contains_sources = { + e.get("source") for e in all_edges if e.get("relation") == "contains" + } + by_path: dict[Path, str] = {} + fallback: dict[Path, str] = {} + for n in all_nodes: + source_file, nid = n.get("source_file"), n.get("id") + if not source_file or not nid: + continue + candidate = Path(str(source_file)) + try: + resolved = ( + candidate if candidate.is_absolute() else root / candidate + ).resolve() + except OSError: + continue + if nid in contains_sources: + by_path.setdefault(resolved, nid) + elif n.get("label") == resolved.name: + fallback.setdefault(resolved, nid) + for resolved, nid in fallback.items(): + by_path.setdefault(resolved, nid) + return by_path + + +def _hint_import_targets(paths, all_edges, root) -> None: + """Tell id-disambiguation which file each stem-named import edge targets. + + The extractors in ``_IMPORT_STEM_LANGUAGES`` name an import's target by the + bare file-node id of the imported file (``import lead`` -> ``lead``), which + resolves only while that id is unique. Add ANY same-stem file — a ``lead.md`` + will do — and the two file nodes collide, so + ``_disambiguate_colliding_node_ids`` salts them apart into ``lead_py_lead`` + and ``lead_md_lead``. The edge's target salt is keyed by the IMPORTER's + source_file, which matches neither, so the edge is left pointing at an id + that no longer names anything: silently dropped, along with everything + downstream of it (Python's ``module.func()`` call resolution among them). + + The disambiguator already accepts a ``target_file`` hint for exactly this + (#1814), keying the target salt by that file instead. Stamp it here rather + than in each extractor, because an extractor sees one file and cannot know + which of the corpus's same-stem candidates the id will end up naming. + + This cannot change WHICH node an edge resolves to — the hint only selects + among the salted variants of an id the edge already named — so a corpus with + no collision is bit-identical. Guards: never overwrite a hint an emitter + already stamped, and skip an id claimed by more than one importable file of + the same language (leave it dangling, as before). + + Must run BEFORE ``_disambiguate_colliding_node_ids``, the hint's only reader. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + hints: list[tuple[tuple[str, ...], dict[str, str]]] = [] + for importer_suffixes, target_suffixes in _IMPORT_STEM_LANGUAGES: + file_id_to_paths: dict[str, set[str]] = {} + for p in paths: + if p.suffix.lower() not in target_suffixes: + continue + try: + rel = Path(p).resolve().relative_to(root) + except (ValueError, OSError): + continue + file_id_to_paths.setdefault(_file_node_id(rel), set()).add(str(p)) + hint_map = { + fid: next(iter(ps)) for fid, ps in file_id_to_paths.items() if len(ps) == 1 + } + if hint_map: + hints.append((importer_suffixes, hint_map)) + if not hints: + return + for e in all_edges: + if not ( + isinstance(e, dict) + and e.get("relation") in ("imports", "imports_from") + and not e.get("target_file") + ): + continue + source_file = str(e.get("source_file", "")).lower() + for importer_suffixes, hint_map in hints: + if not source_file.endswith(importer_suffixes): + continue + target_path = hint_map.get(e.get("target")) + if target_path: + e["target_file"] = target_path + break + + SEMANTIC_RELATIONS = frozenset({ "inherits", "implements", "mixes_in", "embeds", "references", "calls", "imports", "imports_from", "re_exports", "contains", "method", @@ -5682,6 +5808,11 @@ def _learn(e: dict) -> None: # (src/) package root before the resolver/import-evidence passes run, so the # graph is identical regardless of scan root (#2072). _repoint_python_package_imports(paths, all_nodes, all_edges, root) + # Then hint the disambiguator at each stem-named import's real target file, so + # a same-stem sibling cannot strand the edge on a salted-away id (#33). Must + # be after the repoint above (it rewrites some targets) and before + # disambiguation (the hint's only reader). + _hint_import_targets(paths, all_edges, root) _merge_swift_extensions(per_file, all_nodes, all_edges) _merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges, paths, root) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) @@ -5787,7 +5918,10 @@ def _looks_like_bash(result: object) -> bool: sh_paths = [p for _, p in sh_pairs] try: all_edges.extend( - resolve_bash_source_edges(sh_results, sh_paths, root, existing_edges=all_edges) + resolve_bash_source_edges( + sh_results, sh_paths, root, existing_edges=all_edges, + file_nids=_file_nids_by_path(all_nodes, all_edges, root), + ) ) except Exception as exc: import logging diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 892f310650..852f3a91f4 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -406,6 +406,7 @@ def resolve_bash_source_edges( paths: Sequence[Path], root: Path, existing_edges: list[dict] | None = None, + file_nids: dict[Path, str] | None = None, ) -> list[dict]: """Resolve Bash source/import edges and source-backed function calls. @@ -428,7 +429,17 @@ def resolve_bash_source_edges( Anything else is silently skipped. """ path_by_index = [Path(p).resolve() for p in paths] - file_nid_by_path = {p: _file_node_id_for_path(p, root) for p in path_by_index} # resolved paths only + # `file_nids` carries the file node ids as they actually stand in the graph. + # This pass runs AFTER id-disambiguation, so when a same-stem sibling made a + # file id collide, the salted id no longer matches what + # `_file_node_id_for_path` derives from the path — every edge built from the + # formula would then name a node that does not exist (#33). Fall back to the + # formula for any path the caller did not resolve (and for direct callers + # that pass nothing), which is exactly the previous behavior. + known_nids = file_nids or {} + file_nid_by_path = { # resolved paths only + p: known_nids.get(p) or _file_node_id_for_path(p, root) for p in path_by_index + } functions_by_file: dict[str, dict[str, str]] = {} for result, path in zip(per_file, path_by_index): diff --git a/tests/test_import_alias_disambiguation.py b/tests/test_import_alias_disambiguation.py new file mode 100644 index 0000000000..7c1260617b --- /dev/null +++ b/tests/test_import_alias_disambiguation.py @@ -0,0 +1,141 @@ +"""An import edge must survive a same-stem file in another language (#33). + +Several extractors name an import's target by the bare file-stem id of the +imported file (``import lead`` -> ``lead``). That works only while the id is +unique: add ANY same-stem file -- a ``lead.md`` will do -- and the two file +nodes collide, so ``_disambiguate_colliding_node_ids`` salts them apart into +``lead_ex_lead`` and ``lead_md_lead``. The import edge's target salt is keyed by +the IMPORTER's own source_file, which matches neither, so the edge is left +pointing at an id that no longer names anything and is silently dropped. + +The disambiguator already accepts a ``target_file`` hint for exactly this shape +(#1814), keying the target salt by that file instead. Every language below +stamps it now. + +Each case asserts BOTH directions, so a fixture that never produced an import +edge in the first place cannot pass by accident: the control corpus must +resolve, and the collision corpus must resolve to the SAME file. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from graphify.extract import extract + +_IMPORT_RELATIONS = ("imports", "imports_from", "re_exports") + +# A language-neutral collider: it shares the stem, mints a file node, and has +# nothing whatsoever to do with the import under test. +_COLLIDER = ("lead.md", "# Lead\n\nUnrelated notes.\n") + +# (case id, importer file, importer body, target file, target body) +_CASES = [ + ( + "powershell-dot-source", + "caller.ps1", ". ./lead.ps1\nfunction Run { Search }\n", + "lead.ps1", "function Search { return @() }\n", + ), + ( + "powershell-import-module", + "caller.ps1", "Import-Module ./lead.ps1\nfunction Run { Search }\n", + "lead.ps1", "function Search { return @() }\n", + ), + ( + "rust-use", + "caller.rs", "use crate::lead;\n\npub fn run() { lead::search(); }\n", + "lead.rs", "pub fn search() {}\n", + ), + ( + "pascal-uses", + "caller.pas", + "unit Caller;\ninterface\nuses Lead;\nimplementation\nend.\n", + "lead.pas", + "unit Lead;\ninterface\nprocedure Search;\n" + "implementation\nprocedure Search; begin end;\nend.\n", + ), + ( + "zig-at-import", + "caller.zig", + 'const lead = @import("lead.zig");\npub fn run() void { lead.search(); }\n', + "lead.zig", "pub fn search() void {}\n", + ), + ( + "elixir-import", + "caller.ex", + "defmodule Caller do\n import Lead\n def run, do: search()\nend\n", + "lead.ex", "defmodule Lead do\n def search, do: []\nend\n", + ), + ( + "bash-source", + "caller.sh", "source ./lead.sh\nrun() { search; }\n", + "lead.sh", "search() { echo hi; }\n", + ), +] + + +def _extract(tmp_path: Path, files: list[tuple[str, str]]) -> dict: + tmp_path.mkdir(parents=True, exist_ok=True) + paths = [] + for name, body in files: + path = tmp_path / name + path.write_text(body, encoding="utf-8") + paths.append(path) + return extract(paths, cache_root=tmp_path) + + +def _import_targets(result: dict) -> list[dict]: + """The node each import edge points at, or ``None`` where it dangles.""" + by_id = {node["id"]: node for node in result["nodes"]} + return [ + by_id.get(edge.get("target")) + for edge in result["edges"] + if edge.get("relation") in _IMPORT_RELATIONS + ] + + +@pytest.mark.parametrize( + ("importer", "importer_body", "target", "target_body"), + [case[1:] for case in _CASES], + ids=[case[0] for case in _CASES], +) +def test_import_edge_survives_a_same_stem_foreign_sibling( + tmp_path: Path, importer: str, importer_body: str, target: str, target_body: str, +): + corpus = [(importer, importer_body), (target, target_body)] + + control = _import_targets(_extract(tmp_path / "control", corpus)) + assert any( + node is not None and str(node.get("source_file", "")).endswith(target) + for node in control + ), f"fixture is inert: no import edge reached {target} even without a collider" + + collided = _import_targets(_extract(tmp_path / "collided", [*corpus, _COLLIDER])) + assert None not in collided, \ + "an import edge dangled on the pre-disambiguation stem id" + assert any( + str(node.get("source_file", "")).endswith(target) for node in collided + ), f"the import edge no longer reaches {target}" + assert not any( + str(node.get("source_file", "")).endswith(_COLLIDER[0]) for node in collided + ), "an import edge was repointed onto the unrelated collider" + + +def test_the_transient_target_file_hint_never_reaches_the_graph(tmp_path: Path): + """``target_file`` carries an absolute path and is popped by its only reader. + + Asserted across every case at once: a language that stamps the hint but is + somehow not reached by the disambiguator would ship the analysing machine's + filesystem layout inside `graph.json`. + """ + # Two cases share `caller.ps1`; dedupe by name (last wins). The point is + # breadth of emitters in one graph, not per-case isolation. + corpus = {_COLLIDER[0]: _COLLIDER[1]} + for _, importer, importer_body, target, target_body in _CASES: + corpus[importer] = importer_body + corpus[target] = target_body + result = _extract(tmp_path, list(corpus.items())) + + leaked = [edge for edge in result["edges"] if "target_file" in edge] + assert leaked == [], f"transient hint reached the graph: {leaked[:3]}" diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py index 5171c1b5d9..9fcb942a89 100644 --- a/tests/test_mixed_corpus_member_calls.py +++ b/tests/test_mixed_corpus_member_calls.py @@ -615,12 +615,12 @@ def test_typescript_receiver_resolves_despite_a_same_named_python_class(tmp_path # filtering source-backed nodes, and its module arm resolves through the # caller's own `imports` edges -- but both were corpus-wide all the same. # -# The class arm is covered in both directions. The module arm is covered only -# for the leak: its suppression direction is unreachable, because two same-stem -# files disambiguate the FILE node ids (`lead_py_lead`, `lead_ts_lead`) while -# the `imports` edge target stays the bare alias `lead`, so the arm sees zero -# candidates rather than an ambiguous two. That is a separate defect in the -# import-alias remap, tracked as #33 and untouched here. +# Both arms are covered in both directions. The module arm's suppression case +# turned out not to be an index defect at all: two same-stem files disambiguate +# the FILE node ids (`lead_py_lead`, `lead_ts_lead`) while the `imports` edge +# target stays the bare alias `lead`, so the arm saw ZERO candidates rather +# than an ambiguous two. That is fixed one layer down, in the import-alias +# hinting (#33), and asserted at both layers below. _JAVA_DECOY = "class Lead { void search() {} }\n" @@ -697,3 +697,58 @@ def test_python_module_receiver_still_resolves_its_own_module(tmp_path: Path): py_search = _nid(result, "search()", "lead.py") assert (run, py_search) in calls, "the module arm stopped resolving" assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +def test_python_module_receiver_resolves_despite_a_same_stem_typescript_file( + tmp_path: Path, +): + """Module arm, defect 2: a same-stem foreign file must not delete the edge. + + `lead.py` and `lead.ts` both derive the file node id `lead`, so + disambiguation salts them into `lead_py_lead` and `lead_ts_lead` — while the + `imports` edge target stays the bare alias `lead`, which now names nothing. + The arm then sees ZERO candidate modules rather than an ambiguous two, and + the one edge Python's own import genuinely supports disappears (#33). + """ + calls, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, "search()", "lead.py") + ts_search = _nid(result, "search()", "lead.ts") + assert (run, py_search) in calls, \ + "a same-stem TypeScript file suppressed the real Python edge" + assert (run, ts_search) not in calls, "the TypeScript decoy received an edge" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +def test_python_import_edge_survives_a_same_stem_foreign_sibling(tmp_path: Path): + """The `imports` edge itself, one layer below the call edge above (#33). + + Asserted separately because the module arm is only one consumer: any query + over Python imports lost this edge to the same dangling alias. + """ + _, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + node_ids = {node["id"] for node in result["nodes"]} + py_file = _nid(result, "lead.py", "lead.py") + ts_file = _nid(result, "lead.ts", "lead.ts") + imports = { + (edge["source"], edge["target"]) + for edge in result["edges"] + if edge["relation"] in ("imports", "imports_from") + } + caller_file = _nid(result, "caller.py", "caller.py") + assert (caller_file, py_file) in imports, \ + "the Python import edge dangled on the pre-disambiguation alias" + assert (caller_file, ts_file) not in imports, \ + "a Python import must never resolve to a TypeScript file" + for source, target in imports: + assert target in node_ids, f"import edge dangles: {source} -> {target}" From bf0a92c68166db7485d5082b53d7926aa1e45c55 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 10:26:31 -0300 Subject: [PATCH 23/41] feat(extract): bind claimed PHP receiver types to their declared FQNs (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #21 refusal made a claimed name decisive but could only delete edges. This is its additive counterpart: when the FQN a calling file claims for a receiver's type — via `use`, `use ... as`, a group `use`, or a qualified annotation — matches the name some in-corpus file DECLARES for a class (the #14 payload), PhpNameResolver binds it, selecting the imported one of several same-short-named classes and following a renaming alias to a class the written short name would never census. An FQN declared identically by two files poisons its index entry rather than guessing; an unclaimed short name still runs the unique-short-name fallback untouched, and every #16-style refusal stands where no declared FQN matches. Ships together with #23 (the persisted `_php_class_fqns` marker): without it, every edge this adds would exist on full builds only and silently vanish on the first incremental rebuild. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + graphify/extract.py | 13 +- graphify/extractors/php.py | 50 +++++-- tests/test_php_alias_binding.py | 224 ++++++++++++++++++++++++++++++++ tests/test_php_member_calls.py | 6 +- tests/test_php_name_resolver.py | 25 ++-- 6 files changed, 291 insertions(+), 28 deletions(-) create mode 100644 tests/test_php_alias_binding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f0c128e0..f3c6565e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. - Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. - Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. +- Feature: a PHP member call through a receiver whose type name the calling file claims now BINDS to the class whose defining file declares exactly that fully qualified name (`lawnstarter/graphify#22`), completing the `use`-map work above with its recall half. Two things resolve that used to refuse: `use App\Alpha\X;` with `private X $c;` selects `App\Alpha\X` among several same-short-named in-corpus classes (the single-definition guard previously refused the pair), and a renaming alias `use App\Alpha\X as Y;` with `private Y $c;` follows the alias to its target, which no short-name census could find because nothing in the corpus is named `Y`. The match is against the namespace each defining file DECLARES (the same #14 facts the inline-`new` corroboration reads), folded case-insensitively like every other PHP name; an FQN declared identically by two files poisons that entry rather than guessing between them. **Addition scope**, stated deliberately as the mirror of the deletion scope above: this change only ever adds or re-points `calls`/`indirect_call` edges whose receiver type the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — and whose claimed FQN matches a declared one exactly. An unclaimed short name still resolves through the same unique-short-name fallback as before, and every #16-style refusal stands where no declared FQN matches. New edges are INFERRED (0.8), like every receiver-typed binding. - 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) diff --git a/graphify/extract.py b/graphify/extract.py index 5531408e78..3e54d5f437 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3223,12 +3223,13 @@ def key(label: str) -> str: if declared and declared.get("path"): class_fqn_by_file[declared["path"]] = declared.get("classes", {}) - # `use`-import/namespace-aware receiver typing (#21), consulted IN FRONT of - # the corpus-wide short-name index below — the shape of the C# call site in - # `_resolve_csharp_member_calls`. A name the calling file CLAIMS through a - # `use` import or writes out qualified is decided here and never falls back: - # that refusal is the whole fix for #16. It can only delete edges, never add - # or re-point one — see PhpNameResolver. + # `use`-import/namespace-aware receiver typing (#21, #22), consulted IN + # FRONT of the corpus-wide short-name index below — the shape of the C# + # call site in `_resolve_csharp_member_calls`. A name the calling file + # CLAIMS through a `use` import or writes out qualified is decided here and + # never falls back: the refusal is the whole fix for #16, and the claimed + # FQN binding to the class whose file declares exactly that name is the + # recall counterpart (#22) — see PhpNameResolver. resolver = PhpNameResolver(all_nodes, all_edges, type_def_nids, class_fqn_by_file) def declared_fqn(type_node: dict | None) -> str | None: diff --git a/graphify/extractors/php.py b/graphify/extractors/php.py index 093b069d29..5fcc1e8ece 100644 --- a/graphify/extractors/php.py +++ b/graphify/extractors/php.py @@ -136,12 +136,16 @@ class PhpNameResolver: re-parsed, and no new persisted marker is needed: the ``use`` map belongs to the CALLING file, which an incremental rebuild always re-dispatches. - STRICTLY SUBTRACTIVE by construction. Every node this returns is looked up - under the receiver's WRITTEN short name in the very index the fallback - consults, so a positive verdict is always the answer the fallback would have - given; the only behavior change is the refusal. Binding ``use App\\X as Y;`` - to a class the short name ``Y`` does not name, or picking the aliased one of - several same-short-named classes, is a recall ADDITION and belongs to #22. + The refusal (#21) is strictly subtractive; the declared-FQN index layered on + top (#22) is its additive counterpart. When the claimed FQN matches the name + some in-corpus class's own file DECLARES (#14), that match outranks the + short-name census: it picks the imported one of several same-short-named + classes, and follows a renaming alias (``use App\\X as Y;``) to a class the + written short name never would have found. The index only knows classes + whose declared FQNs are available — for a file left undispatched by an + incremental rebuild that is the persisted ``_php_class_fqns`` marker (#23); + a graph written before that marker simply yields no match and the verdict + falls back to the #21 rules, adding no edge rather than a wrong one. """ def __init__( @@ -191,6 +195,22 @@ def __init__( claimed, target_fqn ) + # Declared FQN -> definition node, over the same node universe as the + # short-name index — every binding this enables is one the fallback + # could have made if only it could tell the namesakes apart (#22). An + # FQN declared by two nodes (copied fixtures, vendored duplicates) is + # poisoned rather than answered with either. + self.fqn_def_nid: dict[str, str | None] = {} + for nids in type_def_nids.values(): + for nid in nids: + node = self.node_by_id.get(nid) + declared = self._declared_fqn(node) + if not declared: + continue + fqn_key = _php_key(declared) + if self.fqn_def_nid.setdefault(fqn_key, nid) != nid: + self.fqn_def_nid[fqn_key] = None + # Per file: the namespace its declarations sit in, needed to resolve a # namespace-RELATIVE annotation (`Local\Client`). Derived from the # declared-FQN payload, which covers every file dispatched this run — @@ -266,12 +286,24 @@ def resolve_type_name( if not fqn: return None, False + # The claimed FQN against the names the defining files DECLARE (#22): + # a unique match is the strongest verdict there is — the calling file + # names the class in full and some file declares exactly that class — + # so it outranks the short-name census below, selecting the imported + # one of several namesakes and following a renaming alias to a class + # the written short name would never census. Absent a match (the + # defining file declares no namespace, or was not dispatched and left + # no #23 marker) nothing is asserted either way: fall through, where + # the #21 rules refuse or bind exactly as they did before this index. + declared_nid = self.fqn_def_nid.get(_php_key(fqn)) + if declared_nid is not None: + return declared_nid, True + candidates = self.type_def_nids.get(short, []) if len(candidates) != 1: # Nothing in the corpus answers to the written name, or several - # things do and the fallback would refuse too. Either way there is - # no edge to keep — telling the alias's target apart from its - # namesakes is #22's job, and this ticket may only delete. + # things do — and no declared FQN above told them apart. There + # is no edge the fallback could keep either way: refuse. return None, True node = self.node_by_id.get(candidates[0]) if _php_fqn_names_another_class(fqn, node, self._declared_fqn(node)): diff --git a/tests/test_php_alias_binding.py b/tests/test_php_alias_binding.py new file mode 100644 index 0000000000..8fc4788c87 --- /dev/null +++ b/tests/test_php_alias_binding.py @@ -0,0 +1,224 @@ +"""PHP positive alias binding via the declared-FQN index (#22, #23). + +#21's `PhpNameResolver` made a claimed name DECISIVE — a `use` import whose +target is not in the corpus refuses instead of binding a same-short-named +stranger — but it could only delete edges. This is the additive counterpart: +when the claimed FQN matches the name some in-corpus file DECLARES for a class +(#14's `php_class_fqns` payload), that match binds, selecting the imported one +of several namesakes and following a renaming alias (`use App\\A\\X as Y;`) to +a class the written short name would never census. + +The index only knows classes whose declared FQNs are available. On a full run +that is every dispatched file; on an incremental rebuild the defining file is +NOT re-dispatched, so the declared map rides the persisted `_php_class_fqns` +marker on the file node (#23) — the same channel as `_php_non_class_types`. +A graph written before the marker fails closed: the #22 binding is simply +absent (never guessed), while #21's verdicts still stand on the path evidence. + +Every test goes through the public `extract()` seam, and every positive case +carries same-short-named decoys in other namespaces asserted to get NO edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def _sends(calls, caller: str) -> list[str]: + return [tgt for src, tgt in calls if src == caller] + + +# Two in-corpus namesakes: only the caller's `use` import can tell them apart, +# so the pre-#22 single-definition guard refused both. +_X_A = ( + " str: + return ( + "c->send(); }\n" + "}\n" + ) + + +# ── the binding: a claimed FQN selects among namesakes ──────────────────────── + + +def test_use_selects_the_imported_one_of_two_namesakes(tmp_path: Path): + """Acceptance #1: `use App\\Alpha\\X;` binds the receiver to `App\\Alpha\\X` + and ONLY it — the `App\\Beta\\X` namesake gets nothing.""" + calls, r = _calls(tmp_path, { + **_NAMESAKES, + "app/Http/I.php": _caller("use App\\Alpha\\X;\n"), + }) + + go = _find(r, ".go()", "_go") + alpha = _find(r, ".send()", "alpha") + assert (go, alpha) in calls, "the imported namesake must bind" + assert calls[(go, alpha)]["confidence"] == "INFERRED" + assert _sends(calls, go) == [alpha], "the other namesake must get NO edge" + + +def test_renaming_alias_binds_its_target(tmp_path: Path): + """Acceptance #2: `use App\\Alpha\\X as Y;` with `private Y $c;` follows the + alias to `App\\Alpha\\X`. An unrelated in-corpus class actually NAMED `Y` + is exactly the stranger #21 refused — it must stay refused, not bound.""" + calls, r = _calls(tmp_path, { + **_NAMESAKES, + "app/Other/Y.php": ( + " Date: Thu, 6 Aug 2026 10:29:35 -0300 Subject: [PATCH 24/41] feat(extract): persist PHP declared FQNs for incremental-rebuild parity (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The php_class_fqns payload aligns 1:1 with the files dispatched THIS run, so every edge the #22 declared-FQN binding adds existed on full builds only and silently vanished on the first `graphify update` — the exact failure mode #11 closed for the interface refusal. Stamp each PHP file node with a persisted `_php_class_fqns` marker (the `_callable` / `_php_non_class_types` channel), hand it back on the resolution-context nodes in watch.py and cli.py, and re-inject it as synthetic per-file payload entries so the resolver rebuilds its index for files the incremental pass left untouched. A graph.json written before the marker fails closed, matching the established precedent: the #22 binding is simply absent — never redirected to a namesake, with the #16 refusals still standing on path evidence — until the defining file is re-extracted. Every #22 positive scenario is mirrored under _full_then_incremental, plus pre-marker variants pinning the fail-closed behavior. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + graphify/cli.py | 6 +- graphify/extract.py | 45 ++++++- graphify/extractors/engine.py | 13 +++ graphify/watch.py | 6 +- tests/test_php_alias_binding.py | 201 ++++++++++++++++++++++++++++++++ tests/test_php_member_calls.py | 2 +- tests/test_php_name_resolver.py | 18 +-- 8 files changed, 280 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c6565e77..57b5cd853b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. - Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. - Feature: a PHP member call through a receiver whose type name the calling file claims now BINDS to the class whose defining file declares exactly that fully qualified name (`lawnstarter/graphify#22`), completing the `use`-map work above with its recall half. Two things resolve that used to refuse: `use App\Alpha\X;` with `private X $c;` selects `App\Alpha\X` among several same-short-named in-corpus classes (the single-definition guard previously refused the pair), and a renaming alias `use App\Alpha\X as Y;` with `private Y $c;` follows the alias to its target, which no short-name census could find because nothing in the corpus is named `Y`. The match is against the namespace each defining file DECLARES (the same #14 facts the inline-`new` corroboration reads), folded case-insensitively like every other PHP name; an FQN declared identically by two files poisons that entry rather than guessing between them. **Addition scope**, stated deliberately as the mirror of the deletion scope above: this change only ever adds or re-points `calls`/`indirect_call` edges whose receiver type the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — and whose claimed FQN matches a declared one exactly. An unclaimed short name still resolves through the same unique-short-name fallback as before, and every #16-style refusal stands where no declared FQN matches. New edges are INFERRED (0.8), like every receiver-typed binding. +- To make those bindings survive `graphify update`, each PHP file node now persists the FQNs its classes are declared under as a `_php_class_fqns` marker in `graph.json` (`lawnstarter/graphify#23`), riding the same channel as `_callable` (#2438) and `_php_non_class_types` (#11/#12): the watcher and `graphify update` hand the marker back on the resolution-context nodes, and the resolver rebuilds its declared-FQN index from it for every file the incremental pass left untouched. Without this, every edge #22 adds would exist on full builds only and silently vanish on the first incremental rebuild — the exact failure mode #11 closed for the interface refusal. A `graph.json` written before the marker fails closed, matching the established precedent: the binding is simply absent (never redirected to a namesake, and the #16 refusals still stand on path evidence) until the defining file is re-extracted or `graphify update` runs on it once. - 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) diff --git a/graphify/cli.py b/graphify/cli.py index dc8b03621e..45da373b2d 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3153,8 +3153,12 @@ def _ctx_identity(source_file) -> str | None: # 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. + # `_php_class_fqns` (#23) is the positive counterpart: + # the declared FQNs that let a claimed `use` import keep + # binding into an unchanged defining file (#22). for _marker in ("_callable", "_callable_class", - "_php_non_class_types", "_php_interfaces"): + "_php_non_class_types", "_php_interfaces", + "_php_class_fqns"): 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 3e54d5f437..63289864e9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3137,6 +3137,37 @@ def _php_context_interface_entry(context_nodes: list[dict] | None) -> dict | Non return {"php_non_class_types": names} if names else None +def _php_context_class_fqn_entries(context_nodes: list[dict] | None) -> list[dict]: + """Recover the unchanged corpus's declared PHP class FQNs (#23). + + The ``php_class_fqns`` payload aligns 1:1 with the files dispatched this + run, so on an incremental rebuild the declared-FQN binding (#22) went blind + to every class whose defining file was unchanged, and the edges it adds on + a full build silently vanished. The extractor stamps each PHP file node + with its ``_php_class_fqns`` map — the same persisted-marker channel as + ``_php_non_class_types`` — and this hands the stamps back as synthetic + ``per_file``-shaped entries, one per file since the payload is keyed by + the DEFINING file's path. The path is the context node's own + ``source_file``: that is the exact form the resolver's type nodes carry, + because both come from the same persisted graph. A graph written before + the marker existed yields no entries and the binding fails closed — no + edge rather than a guessed one — until the defining file is re-extracted. + + Read from the RAW context list for the reason `_php_context_interface_entry` + is: an import stub minted by a changed caller can collide with the file + node's id and take the marker down with it in the merge. + """ + entries = [] + for node in context_nodes or []: + classes = node.get("_php_class_fqns") + source_file = node.get("source_file") + if isinstance(classes, dict) and classes and source_file: + entries.append({ + "php_class_fqns": {"path": str(source_file), "classes": classes} + }) + return entries + + def _resolve_php_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -6082,8 +6113,14 @@ def _has_import_evidence(candidate_id: str) -> bool: _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] + # #23: the declared-FQN maps ride the same marker channel, per defining + # file — without them the #22 binding goes blind to every class whose + # file this rebuild left alone, and its edges exist only on full builds. + _php_ctx_fqns = _php_context_class_fqn_entries(resolution_context_nodes) + if _php_ctx_entry is not None or _php_ctx_fqns: + _rl_per_file = [*per_file, + *([_php_ctx_entry] if _php_ctx_entry else []), + *_php_ctx_fqns] _n0, _e0 = len(_rl_nodes), len(_rl_edges) run_language_resolvers(paths, _rl_per_file, _rl_nodes, _rl_edges) all_nodes.extend(_rl_nodes[_n0:]) @@ -6230,6 +6267,10 @@ def _canon(nid: str) -> str: # 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. + # `_php_class_fqns` (#23) persists likewise, failing in the safe direction + # too: a pre-marker graph loses the declared-FQN BINDING (#22) on an + # incremental rebuild — an absent edge, never a guessed one — until the + # defining 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 a77ab63953..9b15c11b56 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5466,6 +5466,19 @@ def _scan_js_module_dispatch(n) -> None: # 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} + # Like `_php_non_class_types` above, the payload only reaches the + # resolver for files dispatched THIS run — but the declared-FQN + # binding (#22) needs the names of the files an incremental rebuild + # left alone, or every edge it adds silently vanishes on the first + # `graphify update` (#23). Stamp the map on the FILE node too; it + # rides graph.json into the resolution context over the same channel + # as the other persisted markers. The path key is deliberately NOT + # stored: at read-back time it is the node's own `source_file`, in + # whatever form that graph carries. + for n in nodes: + if n["id"] == file_nid: + n["_php_class_fqns"] = dict(php_class_fqns) + 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 043a75fc71..c35d8354cb 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1218,8 +1218,12 @@ def _add_deleted_source(path: Path) -> None: # 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. + # `_php_class_fqns` (#23) is the positive counterpart: the + # declared FQNs that let a claimed `use` import keep binding + # into an unchanged defining file (#22). for marker in ("_callable", "_callable_class", - "_php_non_class_types", "_php_interfaces"): + "_php_non_class_types", "_php_interfaces", + "_php_class_fqns"): if node.get(marker): ctx_node[marker] = node[marker] resolution_context_nodes.append(ctx_node) diff --git a/tests/test_php_alias_binding.py b/tests/test_php_alias_binding.py index 8fc4788c87..0c08bbf88b 100644 --- a/tests/test_php_alias_binding.py +++ b/tests/test_php_alias_binding.py @@ -222,3 +222,204 @@ def test_duplicate_declared_fqn_refuses_rather_than_guessing(tmp_path: Path): go = _find(r, ".go()", "_go") assert _sends(calls, go) == [] + + +# ── the same bindings across an incremental rebuild (#23) ───────────────────── +# +# The context below is assembled exactly as watch.py builds it from graph.json: +# a field subset of the persisted AST nodes — including the persisted underscore +# markers, of which `_php_class_fqns` (#23) carries the declared FQNs the #22 +# binding needs — plus 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_non_class_types", + "_php_interfaces", "_php_class_fqns") + + +def _watch_resolution_context(result: dict, unchanged: set[str], + markers: tuple[str, ...] = _CTX_MARKERS): + 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 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, + markers: tuple[str, ...] = _CTX_MARKERS): + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed}, markers=markers + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_CALLER = "app/Http/I.php" + + +def test_namesake_selection_survives_incremental_rebuild(tmp_path: Path): + """Acceptance (#23): the defining files are unchanged and NOT dispatched, + so the declared FQNs reach the resolver only through the persisted marker — + the full and incremental runs must agree edge for edge.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_NAMESAKES, + _INCR_CALLER: _caller("use App\\Alpha\\X;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + # The targets live in unchanged files, so their ids come from the full run. + alpha = _find(full, ".send()", "alpha") + assert _sends(full_calls, go) == [alpha], "full-build baseline" + assert _sends(inc_calls, go) == [alpha], \ + "an undispatched defining file must not lose the #22 binding" + + +def test_renaming_alias_survives_incremental_rebuild(tmp_path: Path): + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_NAMESAKES, + _INCR_CALLER: _caller("use App\\Alpha\\X as Y;\n", annotation="Y"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + alpha = _find(full, ".send()", "alpha") + assert _sends(full_calls, go) == [alpha] + assert _sends(inc_calls, go) == [alpha] + + +def test_written_fqn_selection_survives_incremental_rebuild(tmp_path: Path): + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_NAMESAKES, + _INCR_CALLER: _caller(annotation="\\App\\Alpha\\X"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + alpha = _find(full, ".send()", "alpha") + assert _sends(full_calls, go) == [alpha] + assert _sends(inc_calls, go) == [alpha] + + +# ── pre-marker graphs: a graph.json written before #23 ──────────────────────── + +_PRE_23_MARKERS = tuple(m for m in _CTX_MARKERS if m != "_php_class_fqns") + + +def test_pre_marker_graph_fails_closed(tmp_path: Path): + """Acceptance (#23): a graph persisted before the marker existed carries no + declared FQNs, so the rebuild cannot tell the namesakes apart — it must add + NO edge (fail closed, matching the `_callable` precedent) and not crash, + until the defining files are re-extracted.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_NAMESAKES, + _INCR_CALLER: _caller("use App\\Alpha\\X;\n"), + }, changed=_INCR_CALLER, markers=_PRE_23_MARKERS) + + go = _find(inc, ".go()", "_go") + assert _sends(full_calls, go) == [_find(full, ".send()", "alpha")], \ + "full-build baseline still binds" + assert _sends(inc_calls, go) == [], \ + "a pre-#23 graph must lose the binding, never misdirect it" + + +def test_pre_marker_graph_keeps_the_21_refusal(tmp_path: Path): + """Fail-closed must not curdle into fail-open elsewhere: with no marker, + an out-of-corpus claim still refuses on the path evidence (#21).""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Alpha/X.php": _X_A, + _INCR_CALLER: _caller("use Vendor\\Sdk\\X;\n"), + }, changed=_INCR_CALLER, markers=_PRE_23_MARKERS) + + go = _find(inc, ".go()", "_go") + assert _sends(inc_calls, go) == [] + + +def test_pre_marker_graph_keeps_the_unique_name_binding(tmp_path: Path): + """And the pre-#22 recall is untouched: a `use` naming the lone in-corpus + class still binds through the PSR-4 path corroboration, exactly as the + incremental path behaved before the marker existed (pinned here after + `test_php_name_resolver.py`'s incremental tests moved onto the marker).""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Alpha/X.php": _X_A, + "app/Audit/Recorder.php": ( + " `src/`), + and with the declaration unavailable the shorter path must not read as a + contradiction — a stripped prefix looks exactly like one.""" + caller = ( + "o->ping(); }\n" + "}\n" + ) + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "src/Odd.php": ( + " `src/`), so `App\\Weird\\Odd` legitimately lives at `src/Odd.php`. The full - run corroborates the `use` against the namespace that file DECLARES; the - incremental run no longer has the declaration and must not read the shorter - path as a contradiction — a stripped prefix looks exactly like one.""" + run corroborates the `use` against the namespace that file DECLARES, and + since #23 the incremental run reads the same declaration off the persisted + marker. `test_php_alias_binding.py` pins the pre-marker variant, where the + shorter path must not read as a contradiction — a stripped prefix looks + exactly like one.""" caller = ( " Date: Thu, 6 Aug 2026 13:53:58 -0300 Subject: [PATCH 25/41] feat(serve): demote relational-intent terms from the seed guarantee (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Who calls ChargeCustomerService?" / "callers of X" used to seed an unrelated decoy: relational words survive stopwording, their junk matches lose everywhere on merit, but the #1445 per-term guarantee hands every query term an unconditional seed slot — so a verb's incidental prefix decoy (a `callStoreWithAmount()` test helper prefix-matching "calls") entered the traversal and spent the budget on an unrelated test neighborhood. C1: `_RELATIONAL_INTENT_TERMS`, derived from `_CONTEXT_HINTS` + `_CONTEXT_FILTER_ALIASES` plus a documented caller/use/listen extension set (those two families appear in neither table), and a demotion step in `_query_graph_text` that filters `best_seed_by_term` between `_score_query` and `_pick_seeds`. That placement keeps the scorer byte-identical and the picker's signature and semantics unchanged for every other caller, so the change is inert outside the natural-language query pipeline. This is demotion, not stopwording: relational terms still score, still compete in the gap window, so a corpus symbol literally named `calls` keeps its exact-match dominance. An all-relational query keeps the unfiltered map, mirroring `_query_terms`' all-stopword fallback. Also adds the synthetic seeding fixture from the spec (service class, three known callers, verb-prefix decoy with a busy test-method neighborhood, generic-noun hub, `coder.md` doc decoy) as a reusable helper for #41-#43. Refs #37 --- graphify/serve.py | 60 ++++++++++- tests/seeding_fixtures.py | 202 ++++++++++++++++++++++++++++++++++++ tests/test_serve_seeding.py | 107 +++++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 tests/seeding_fixtures.py create mode 100644 tests/test_serve_seeding.py diff --git a/graphify/serve.py b/graphify/serve.py index 1f5e99330d..fc6622543d 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -770,6 +770,59 @@ def _seed_label_key(nid: str) -> str: } +# Words that name the *relation* a question asks about ("who CALLS X", "CALLERS +# of X", "what USES X") rather than naming a symbol in the corpus. They lose the +# unconditional seat the #1445 per-term seed guarantee hands every query term — +# and nothing else. They are NOT stopworded: they still score, still compete in +# the gap window, so a corpus symbol literally named `calls`/`uses` keeps its +# exact-match dominance and stays seedable on merit (and `explain`/`path`/ +# `find_node` never take this code path at all). +# +# Why: a relational verb's junk matches lose everywhere on merit, but the +# guarantee hands them a seat anyway, so an incidental prefix decoy — a +# `callStoreWithAmount()` test helper prefix-matching "calls" — enters the +# traversal and spends the budget on an unrelated test neighborhood instead of +# the queried identifier's. See fork #37 (C1) / upstream #2507 direction 2. +# +# The vocabulary is derived from the two tables that already encode relational +# intent, so there is one auditable definition instead of a scattered word list: +# every `_CONTEXT_HINTS` word plus every `_CONTEXT_FILTER_ALIASES` key. The +# extension set exists because "callers"/"uses" appear in NEITHER table (they +# never trigger the traversal-filter heuristic) yet carry exactly the same +# intent — an intent-consumed-only rule misses two of the three measured +# phrasings. `_CONTEXT_HINTS` itself stays untouched on purpose: extending it +# would change which queries get context-filtered, a separate behavior change. +_RELATIONAL_INTENT_TERMS: frozenset[str] = frozenset( + {word for _ctx, hints in _CONTEXT_HINTS for word in hints} + | set(_CONTEXT_FILTER_ALIASES) + | {"caller", "callers", "use", "uses", "used", "using", "usage", + "listen", "listens", "listener", "listeners"} +) + + +def _demote_relational_intent_terms(best_seed_by_term: dict[str, str]) -> dict[str, str]: + """Drop relational-intent terms from the per-term seed guarantee map. + + Applied by `_query_graph_text` between `_score_query` and `_pick_seeds`, + which is the whole point of the placement: `_score_query` stays + byte-identical (its legacy-equality property tests and the query-scoring + benchmark's equality gate hold untouched) and `_pick_seeds` keeps its + signature and semantics for every other caller, so the change is inert + outside the natural-language query pipeline. + + All-relational fallback: if demotion would empty a non-empty map — the + query is nothing but relational words, e.g. `graphify query "calls"` — keep + it unfiltered, mirroring `_query_terms`' all-stopword fallback. Such a query + has no identifier to seed from, so the guarantee is all it has. + """ + kept = { + term: nid + for term, nid in best_seed_by_term.items() + if term not in _RELATIONAL_INTENT_TERMS + } + return kept or best_seed_by_term + + def _normalize_context_filters(filters: list[str] | None) -> list[str]: if not filters: return [] @@ -1105,7 +1158,12 @@ def _query_graph_text( # time; on a 100k-node, three-term benchmark ~71% of scoring time was # spent in those redundant per-term passes. qs = _score_query(G, terms, collect_per_term_seeds=True) - start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + # A relational word ("calls", "callers", "uses") describes the question, not + # a symbol to start from, so it forfeits its unconditional per-term seed + # here — after scoring, before picking — while still competing for a seed on + # merit through the gap window below (#37/C1). + best_seed_by_term = _demote_relational_intent_terms(qs.best_seed_by_term) + start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term) if not start_nodes: return "No matching nodes found." resolved_filters, filter_source = _resolve_context_filters(question, context_filters) diff --git a/tests/seeding_fixtures.py b/tests/seeding_fixtures.py new file mode 100644 index 0000000000..420108477d --- /dev/null +++ b/tests/seeding_fixtures.py @@ -0,0 +1,202 @@ +"""Synthetic corpus fixture for the seeding-hygiene work (fork issue #37). + +Shared by the three slices of the consolidated spec — C1 relational-intent +demotion (#40), C2 covered-term guarantee skip (#41), C3 heuristic-filter +starvation fallback (#42) — so all of them grade the same three phrasings +against one corpus instead of re-inventing a graph each time. + +The shape is the sketch from the spec's Testing Decisions, distilled from the +measured 48.5k-node repro graph: + +* a **service class** (`ChargeCustomerService`) with one member method, wired + the way extraction really wires it: the class->method edge carries + ``context=None``, so a ``call`` context filter cannot leave the class node. +* **three known callers**, two through real ``call``-context edges and one + (`BalanceCustomerAccountService.settle()`) reachable only through a + ``references`` edge — the docblock-typed-receiver extraction gap filed as + #38, deliberately reproduced rather than papered over. +* a **camelCase verb-prefix decoy** (`callStoreWithAmount()`) whose normalized + label prefix-matches the query word "calls", sitting in a **busy test-method + neighborhood** — the junk that the per-term seed guarantee used to buy an + unconditional seat. Two of its neighbors carry the other relational words the + measured phrasings use ("callers", "uses"). +* a **generic-noun hub class** (`Customer`) with a wide member fan-out, one + reference hop away from the service — the explosion C2 targets. +* a **doc file** (`coder.md`) that prefix-matches the query word "code". + +The graph is undirected with per-edge ``_src``/``_tgt`` markers, exactly how +``graphify query`` loads a graph (cli.py: query keeps the graph undirected so +BFS reaches callers as well as callees, and preserves direction per edge for +rendering). Tests that drive ``_query_graph_text`` therefore see what the CLI +sees. +""" +from __future__ import annotations + +import ast + +import networkx as nx + +# --- node ids, exported so tests never hard-code string literals ------------- + +SERVICE = "app/Services/ChargeCustomerService.php::ChargeCustomerService" +SERVICE_METHOD = "app/Services/ChargeCustomerService.php::ChargeCustomerService.charge" +CALLERS = ( + "app/Http/Controllers/BillingController.php::BillingController.processPayment", + "app/Jobs/SubscriptionRenewalJob.php::SubscriptionRenewalJob.handle", + "app/Services/BalanceCustomerAccountService.php::BalanceCustomerAccountService.settle", +) +DECOY = "tests/Feature/StorePaymentTest.php::callStoreWithAmount" +CALLERS_DECOY = "tests/Feature/StorePaymentTest.php::StorePaymentTest.testCallersAreNotified" +USES_DECOY = "tests/Feature/StorePaymentTest.php::StorePaymentTest.testStoreUsesRetryPolicy" +HUB = "app/Models/Customer.php::Customer" +DOC = "docs/coder.md" +CALLS_SYMBOL = "app/Support/EventLog.php::EventLog.calls" + +# Labels are what the rendered output and the `Start:` header actually show, so +# assertions read these rather than node ids. +LABELS = { + SERVICE: "ChargeCustomerService", + SERVICE_METHOD: "ChargeCustomerService.charge()", + CALLERS[0]: "BillingController.processPayment()", + CALLERS[1]: "SubscriptionRenewalJob.handle()", + CALLERS[2]: "BalanceCustomerAccountService.settle()", + DECOY: "callStoreWithAmount()", + CALLERS_DECOY: "StorePaymentTest.testCallersAreNotified()", + USES_DECOY: "StorePaymentTest.testStoreUsesRetryPolicy()", + HUB: "Customer", + DOC: "coder.md", + CALLS_SYMBOL: "calls", +} + +_OTHER_TEST_METHODS = ( + "testRefundIsIssued", + "testTimeoutIsRetried", + "testAmountIsRounded", + "testIdempotencyKeyIsStable", + "testCurrencyIsValidated", + "testPartialFailureIsLogged", +) + +_HUB_MEMBERS = ( + "getName", "getEmail", "getPhone", "getAddress", "isActive", "markActive", + "subscriptions", "invoices", "notes", "toArray", "scopeActive", "fresh", +) + +_DOC_SECTIONS = ("Overview", "Setup", "Runbook") + + +def label_of(node_id: str) -> str: + """The rendered label for a fixture node id.""" + return LABELS[node_id] + + +def caller_labels() -> list[str]: + """Labels of the three ground-truth callers of the service.""" + return [LABELS[nid] for nid in CALLERS] + + +def _add(G: nx.Graph, nid: str, label: str, src: str, loc: str, community: int) -> None: + G.add_node(nid, label=label, source_file=src, source_location=loc, community=community) + + +def _link(G: nx.Graph, src: str, tgt: str, relation: str, context: str | None) -> None: + # `_src`/`_tgt` preserve the logical direction on an undirected graph, the + # same way `graphify query` loads one. + G.add_edge(src, tgt, relation=relation, context=context, + confidence="EXTRACTED", _src=src, _tgt=tgt) + + +def make_charge_fixture(*, calls_symbol: bool = False) -> nx.Graph: + """Build the shared seeding fixture. + + `calls_symbol` adds a corpus-legit production symbol literally labelled + `calls` (an `EventLog.calls` property). It is off by default so the + phrasing tests see the same corpus the measurements were taken on, and on + only for the test that pins "demotion is not stopwording": that symbol must + still win a seed on exact-match merit. + """ + G = nx.Graph() + + # --- the service and its three known callers ----------------------------- + _add(G, SERVICE, LABELS[SERVICE], "app/Services/ChargeCustomerService.php", "L12", 0) + _add(G, SERVICE_METHOD, LABELS[SERVICE_METHOD], + "app/Services/ChargeCustomerService.php", "L28", 0) + # Class -> member edges carry no context: this is why a heuristic `call` + # filter strands a class-node seed (the co-cause C3 addresses). + _link(G, SERVICE, SERVICE_METHOD, "method", None) + + _add(G, CALLERS[0], LABELS[CALLERS[0]], + "app/Http/Controllers/BillingController.php", "L40", 0) + _link(G, CALLERS[0], SERVICE_METHOD, "calls", "call") + + _add(G, CALLERS[1], LABELS[CALLERS[1]], + "app/Jobs/SubscriptionRenewalJob.php", "L22", 0) + _link(G, CALLERS[1], SERVICE_METHOD, "calls", "call") + + # Third caller: its receiver is a docblock-typed, constructor-assigned + # property, so extraction never emits the `calls` edge (#38). It stays + # reachable through the import/reference edge it does get. + _add(G, CALLERS[2], LABELS[CALLERS[2]], + "app/Services/BalanceCustomerAccountService.php", "L31", 0) + _link(G, CALLERS[2], SERVICE, "references", "import") + + # --- the verb-prefix decoy and its busy test neighborhood ---------------- + test_file = "tests/Feature/StorePaymentTest.php" + _add(G, DECOY, LABELS[DECOY], test_file, "L44", 1) + for nid, loc in ( + (CALLERS_DECOY, "L58"), + (USES_DECOY, "L71"), + ): + _add(G, nid, LABELS[nid], test_file, loc, 1) + _link(G, nid, DECOY, "calls", "call") + for i, name in enumerate(_OTHER_TEST_METHODS): + nid = f"{test_file}::StorePaymentTest.{name}" + _add(G, nid, f"StorePaymentTest.{name}()", test_file, f"L{80 + i * 12}", 1) + _link(G, nid, DECOY, "calls", "call") + + # --- the generic-noun hub, one reference hop from the service ------------ + _add(G, HUB, LABELS[HUB], "app/Models/Customer.php", "L9", 0) + _link(G, SERVICE_METHOD, HUB, "references", "parameter_type") + for i, name in enumerate(_HUB_MEMBERS): + nid = f"app/Models/Customer.php::Customer.{name}" + _add(G, nid, f"Customer.{name}()", "app/Models/Customer.php", f"L{20 + i * 9}", 0) + _link(G, HUB, nid, "method", None) + + # --- the doc file that prefix-matches "code" ----------------------------- + _add(G, DOC, LABELS[DOC], "docs/coder.md", "L1", 2) + for i, section in enumerate(_DOC_SECTIONS): + nid = f"docs/coder.md#{section}" + _add(G, nid, f"coder.md#{section}", "docs/coder.md", f"L{10 + i * 20}", 2) + _link(G, DOC, nid, "contains", None) + + # --- optional: a production symbol literally named `calls` --------------- + if calls_symbol: + owner = "app/Support/EventLog.php::EventLog" + _add(G, owner, "EventLog", "app/Support/EventLog.php", "L7", 0) + _add(G, CALLS_SYMBOL, LABELS[CALLS_SYMBOL], "app/Support/EventLog.php", "L15", 0) + _link(G, owner, CALLS_SYMBOL, "field", "field") + + return G + + +def start_labels(text: str) -> list[str]: + """Seed labels parsed out of a `_query_graph_text` header's `Start: [...]`. + + Seed assertions read this rather than the whole body, so "is it seeded?" + can never be confused with "did it get traversed into?", and exact list + membership keeps `ChargeCustomerService` from matching + `ChargeCustomerService.charge()`. + """ + for part in text.split("\n", 1)[0].split(" | "): + if part.startswith("Start:"): + return ast.literal_eval(part[len("Start:"):].strip()) + raise AssertionError(f"no Start: segment in header: {text.splitlines()[:1]}") + + +def shown_nodes(text: str) -> list[str]: + """Labels of the NODE lines that actually survived the token budget.""" + labels = [] + for line in text.splitlines(): + if line.startswith("NODE "): + labels.append(line[len("NODE "):].split(" [", 1)[0]) + return labels diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py new file mode 100644 index 0000000000..c75c366a22 --- /dev/null +++ b/tests/test_serve_seeding.py @@ -0,0 +1,107 @@ +"""Seeding hygiene for natural-language impact questions (fork issue #37). + +C1 — relational-intent demotion: a word that names the *relation* being asked +about ("calls", "callers", "uses") no longer buys an unconditional seed through +the per-term guarantee (#1445). It still scores, still competes for seeds on +merit, and is never stopworded. + +Assertions read external behavior only — the `Start:` header, the rendered NODE +lines, and the seed lists the existing seeding tests already assert on — never +the vocabulary set itself, so tuning the vocabulary can't break these tests. +""" +from graphify.serve import _pick_seeds, _query_graph_text, _query_terms, _score_query + +from tests.seeding_fixtures import ( + CALLERS_DECOY, + CALLS_SYMBOL, + DECOY, + SERVICE, + USES_DECOY, + caller_labels, + label_of, + make_charge_fixture, + shown_nodes, + start_labels, +) + + +def test_who_calls_phrasing_does_not_seed_verb_prefix_decoy(): + """"Who calls ChargeCustomerService?" must not seed `callStoreWithAmount()`. + + The decoy loses everywhere on merit (it scores ~9x below the gap-window + cutoff); it only ever entered the seed list because "calls" held an + unconditional per-term seat. Seed-level only: the `call` context filter this + phrasing infers strands the class seed, which is #42's problem, not this one. + """ + G = make_charge_fixture() + seeds = start_labels( + _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + ) + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds, f"verb-prefix decoy still seeded: {seeds}" + + +def test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed(): + """"callers of X" — the agent-noun phrasing, which triggers no context + filter — must render all three known callers with no junk seed and no junk + neighborhood in the shown output.""" + G = make_charge_fixture() + text = _query_graph_text(G, "callers of ChargeCustomerService", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert label_of(SERVICE) in seeds + assert label_of(CALLERS_DECOY) not in seeds, f"junk test-method seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + # The junk seed's whole neighborhood is what used to eat the budget. + assert label_of(DECOY) not in shown + assert label_of(CALLERS_DECOY) not in shown + + +def test_all_relational_query_keeps_its_per_term_guarantee(): + """A query made only of relational words keeps the guarantee (mirroring the + all-stopword fallback in `_query_terms`): demoting every term would leave + nothing to guarantee, so the winner map is kept unfiltered. + + "uses"' winner scores ~25x below the gap-window cutoff here, so it is seeded + only if the guarantee survives — which is what makes this test load-bearing + rather than decorative. + """ + G = make_charge_fixture() + seeds = start_labels(_query_graph_text(G, "calls uses", mode="bfs", depth=2)) + assert label_of(DECOY) in seeds + assert label_of(USES_DECOY) in seeds, ( + f"all-relational query lost its per-term guarantee: {seeds}" + ) + + +def test_relational_word_with_exact_match_still_seeds_on_merit(): + """Demotion is not stopwording: a corpus symbol literally labelled `calls` + keeps its exact-match dominance and is seeded through the ordinary gap + window, while the unrelated verb-prefix decoy stays out.""" + G = make_charge_fixture(calls_symbol=True) + seeds = start_labels( + _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + ) + assert label_of(CALLS_SYMBOL) in seeds, ( + f"exact-match `calls` symbol lost its seed: {seeds}" + ) + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds + + +def test_scorer_and_picker_are_unchanged_for_direct_callers(): + """C1 is wired from the query pipeline only. Callers that drive the scorer + and the seed picker directly — `path`, `explain`, the legacy-equality + property tests, the benchmark's two arms — must still see the relational + term score and still receive its guaranteed seed.""" + G = make_charge_fixture() + terms = _query_terms("Who calls ChargeCustomerService?") + qs = _score_query(G, terms, collect_per_term_seeds=True) + + assert qs.best_seed_by_term.get("calls") == DECOY, ( + "scorer stopped recording the relational term's per-term winner" + ) + seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + assert DECOY in seeds, "demotion leaked into _pick_seeds' semantics" From cebf231b907789b48f16465c4c53aa5a5aeda774 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 14:11:11 -0300 Subject: [PATCH 26/41] feat(serve): skip the seed guarantee for already-covered terms (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1445 per-term seed guarantee exists to keep a term from being starved, but it fires unconditionally, so a natural-language question's generic nouns each drag in their own hub seed even when the queried identifier already matches them. "what code uses ChargeCustomerService to charge a customer" seeded `Customer` and `.charge()` alongside `ChargeCustomerService`, and the hub's member fan-out then made up the bulk of the answer (measured on the fixture: 12 of 22 nodes; 2,206 of 2,307 on the pinned repro graph). Add a keyword-only, default-off `skip_covered_terms` to `_pick_seeds`: in the guarantee loop, skip a term that is a substring of an already-picked seed's normalized label — the same predicate as _score_nodes' weakest match tier, so "covered" means the seed would have matched the term in scoring. It runs in the existing sorted-term order, layered after the #1766 label-dedup gate, and `_query_graph_text` is the only caller that opts in. This refines the guarantee's invariant to *every term with any match is matched by at least one seed* (was: every term's own singleton winner gets a slot). A genuinely starved term — one no picked seed's label matches — still gets its seed, so the starvation protection survives. Fixture measurements at depth 2: phrasing C goes 22 nodes with the hub and `.charge()` seeded to 10 with neither, 3/3 known callers rendered either way. The `code` -> `coder.md` guarantee seed is the documented residual (#37 Out of Scope): it is covered by no seed's label, so it is not starved. Refs #37, #41 --- graphify/serve.py | 33 ++++++++-- tests/test_serve_seeding.py | 118 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/graphify/serve.py b/graphify/serve.py index fc6622543d..e919ec747d 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -637,6 +637,7 @@ def _pick_seeds( *, G: "nx.Graph | None" = None, best_seed_by_term: dict[str, str] | None = None, + skip_covered_terms: bool = False, ) -> list[str]: """Select BFS seed nodes, stopping when score drops too far below the top. @@ -667,6 +668,18 @@ def _pick_seeds( nodes back inside the gap window; this per-term guarantee remains load-bearing for relevant nodes matched only via substrings, whose flat scores a dampened collision can still exceed. + + `skip_covered_terms` (default off, opted into only by `_query_graph_text`) + refines that guarantee to *every term with any match is matched by at least + one seed*, from *every term's own singleton winner gets a slot*: a term that + is a substring of an already-picked seed's normalized label — the same + predicate as _score_nodes' weakest match tier, so the seed would have + matched the term in scoring — is not starved, and starvation is the only + thing the guarantee exists to prevent. Without it, a natural-language + question's generic nouns each drag in their own hub: "what code uses + ChargeCustomerService to charge a customer" seeds the `Customer` model and + `.charge()` on top of `ChargeCustomerService`, whose label already matches + both terms, and the hub's member fan-out then floods the traversal (#37/C2). """ if not scored: return [] @@ -716,9 +729,15 @@ def _seed_label_key(nid: str) -> str: # Honor the same per-label cap so the per-term guarantee can't # reintroduce a second copy of an already-seeded generic label. key = _seed_label_key(best_nid) - if best_nid not in seeds and key not in seen_labels: - seen_labels.add(key) - seeds.append(best_nid) + if best_nid in seeds or key in seen_labels: + continue + # Layered after that dedup gate, in this same sorted-term order, so + # the coverage check sees the gap-window seeds plus every guarantee + # seed appended so far. + if skip_covered_terms and any(term in _seed_label_key(s) for s in seeds): + continue + seen_labels.add(key) + seeds.append(best_nid) return seeds @@ -1163,7 +1182,13 @@ def _query_graph_text( # here — after scoring, before picking — while still competing for a seed on # merit through the gap window below (#37/C1). best_seed_by_term = _demote_relational_intent_terms(qs.best_seed_by_term) - start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term) + # `skip_covered_terms` is the other half of the seed hygiene: a term an + # already-picked seed's label matches is not starved, so it claims no extra + # seed and a generic noun stops dragging in its own hub (#37/C2). Opted into + # here only, so every other `_pick_seeds` caller keeps the legacy guarantee. + start_nodes = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=best_seed_by_term, skip_covered_terms=True + ) if not start_nodes: return "No matching nodes found." resolved_filters, filter_source = _resolve_context_filters(question, context_filters) diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py index c75c366a22..64a707cc99 100644 --- a/tests/test_serve_seeding.py +++ b/tests/test_serve_seeding.py @@ -105,3 +105,121 @@ def test_scorer_and_picker_are_unchanged_for_direct_callers(): ) seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) assert DECOY in seeds, "demotion leaked into _pick_seeds' semantics" + + +# ===== C2 — covered-term guarantee skip (#41) ================================ +# +# A query term that an already-picked seed's normalized label plainly matches — +# the scorer's own weakest tier, so "this seed would have matched the term in +# scoring" — is not *starved*, and starvation is the only thing the #1445 +# guarantee exists to prevent. It therefore claims no additional seed. The +# refined invariant: every term with any match is matched by at least one seed +# (previously: every term's own singleton winner gets a slot). +# +# Assertions here read external behavior only: the `Start:` header, the rendered +# NODE lines, the header's node count, and the seed lists the prior seeding +# tests already assert on. + +from graphify.serve import _bfs, _demote_relational_intent_terms # noqa: E402 + +from tests.seeding_fixtures import DOC, HUB # noqa: E402 + +_GENERIC_NOUN_QUESTION = "what code uses ChargeCustomerService to charge a customer" +# The hub's 12 members sit one `references` hop past the service, so they enter a +# depth-2 traversal only when the hub itself is seeded. Depth 2 is also the depth +# the spec's measurements were taken at. +_GENERIC_NOUN_DEPTH = 2 +# Terms that exercise both sides of the refined invariant on this fixture: +# "customer" is covered by the `ChargeCustomerService` seed's label, "code" is +# covered by no seed at all (its winner is the `coder.md` prefix decoy — the +# documented residual, and here the load-bearing proof that recovery survives). +_COVERED_AND_STARVED_QUESTION = "ChargeCustomerService customer code" + + +def _nodes_found(text: str) -> int: + """The `N nodes found` count from a `_query_graph_text` header.""" + for part in text.split("\n", 1)[0].split(" | "): + if part.endswith(" nodes found"): + return int(part.split(" ", 1)[0]) + raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") + + +def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): + """"what code uses X to charge a customer" must not seed the `Customer` hub. + + "customer" and "charge" are both substrings of the `ChargeCustomerService` + seed's label, so neither is starved and neither buys a seat. Measured on this + fixture: 22 nodes with the hub seeded (12 of them the hub's member fan-out) + against 10 without it — so the bound is asserted against the pre-C2 seed + list's own traversal rather than a hard-coded number. This phrasing triggers + no context filter, so the comparison traversal is unfiltered like the + pipeline's. + """ + G = make_charge_fixture() + text = _query_graph_text( + G, _GENERIC_NOUN_QUESTION, mode="bfs", depth=_GENERIC_NOUN_DEPTH + ) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert label_of(SERVICE) in seeds + assert label_of(HUB) not in seeds, f"generic-noun hub still seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + + hub_fanout = [lbl for lbl in shown if lbl.startswith(label_of(HUB) + ".")] + assert not hub_fanout, f"hub member fan-out flooded the answer: {hub_fanout}" + + qs = _score_query(G, _query_terms(_GENERIC_NOUN_QUESTION), collect_per_term_seeds=True) + pre_c2_seeds = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=_demote_relational_intent_terms(qs.best_seed_by_term) + ) + pre_c2_nodes, _edges = _bfs(G, pre_c2_seeds, _GENERIC_NOUN_DEPTH) + assert _nodes_found(text) < len(pre_c2_nodes), ( + f"traversal not bounded: {_nodes_found(text)} nodes from {seeds} is no smaller " + f"than the {len(pre_c2_nodes)} the pre-C2 seed list {pre_c2_seeds} reached" + ) + + +def test_covered_term_skips_guarantee_while_starved_term_is_still_recovered(): + """The picker seam, where the starvation-recovery and dedup prior art sits. + + Both halves of the refined invariant in one test: the covered term loses its + guaranteed seat, and the genuinely starved term keeps its — C2 provably + cannot reintroduce starvation. + """ + G = make_charge_fixture() + qs = _score_query( + G, _query_terms(_COVERED_AND_STARVED_QUESTION), collect_per_term_seeds=True + ) + seeds = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=True + ) + + assert SERVICE in seeds + assert HUB not in seeds, ( + "'customer' is a substring of the picked seed's label, so it is not starved" + ) + assert DOC in seeds, ( + "'code' is matched by no picked seed's label — the #1445 guarantee must still fire" + ) + + +def test_covered_term_skip_is_off_unless_the_caller_opts_in(): + """Default-off: identical results for every caller that does not opt in. + + `path`, `explain`, the legacy-equality property tests and the benchmark's two + arms all reach `_pick_seeds` without the flag; passing it explicitly False + must be the same call. + """ + G = make_charge_fixture() + qs = _score_query( + G, _query_terms(_COVERED_AND_STARVED_QUESTION), collect_per_term_seeds=True + ) + legacy = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + + assert legacy == _pick_seeds( + qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=False + ) + assert HUB in legacy, f"the covered-term skip leaked into the default picker: {legacy}" + assert DOC in legacy From 4e8ff46bf49a20adbe0f394ceb29ca0fd17ac2c1 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 14:07:32 -0300 Subject: [PATCH 27/41] feat(serve): relax a starving heuristic context filter (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Who calls ?" infers a `call` context filter, but a class node owns no call edges — calls attach to its methods and the class->member edge carries `context=None` — so the filtered traversal cannot leave the seed and the query answers with one node, confidently. After traversal, when the active filters came from the heuristic and nothing was discovered beyond the seeds, retraverse the unfiltered graph and annotate the `Context:` header, so the answer is neither near-empty nor silently unfiltered. The zero-expansion threshold is deliberate: "few nodes" would need a tuning constant, and a filter that found something is doing its job. Applies identically to BFS and DFS. An explicit context filter is an instruction, not a guess, and is never overridden — even when it strands the traversal. Implements C3 of #37. --- graphify/serve.py | 25 ++++++- tests/test_serve_seeding.py | 141 ++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) diff --git a/graphify/serve.py b/graphify/serve.py index fc6622543d..02a7d2d301 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1168,13 +1168,34 @@ def _query_graph_text( return "No matching nodes found." resolved_filters, filter_source = _resolve_context_filters(question, context_filters) traversal_graph = _filter_graph_by_context(G, resolved_filters) - nodes, edges = _dfs(traversal_graph, start_nodes, depth) if mode == "dfs" else _bfs(traversal_graph, start_nodes, depth) + traverse = _dfs if mode == "dfs" else _bfs + nodes, edges = traverse(traversal_graph, start_nodes, depth) + # A guessed filter that reaches nothing is worse than no filter: "Who calls + # ChargeCustomerService?" infers a `call` filter, but a class node owns no + # call edges — calls land on its methods and the class->member edge carries + # `context=None` — so the filtered traversal cannot leave the seed and the + # answer comes back near-empty yet confident. Retraverse unfiltered instead, + # and say so in the header so a relaxed answer never reads as a filtered one + # (#37/C3). + # + # Both traversals visit every seed, so `nodes <= set(start_nodes)` means the + # traversal discovered nothing at all. That zero-expansion threshold is + # deliberate: "few nodes" would need a tuning constant, and a filter that + # found *something* is doing its job. Mode-independent by construction. + # + # Only the heuristic is second-guessed. An explicitly requested filter is an + # instruction, not a guess, and is honored even when it strands the seeds. + relaxed = filter_source == "heuristic" and nodes <= set(start_nodes) + if relaxed: + traversal_graph = G + nodes, edges = traverse(G, start_nodes, depth) header_parts = [ f"Traversal: {mode.upper()} depth={depth}", f"Start: {[G.nodes[n].get('label', n) for n in start_nodes]}", ] if resolved_filters: - header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})") + note = "; relaxed — no matches beyond seeds" if relaxed else "" + header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source}{note})") header_parts.append(f"{len(nodes)} nodes found") header = " | ".join(header_parts) + "\n\n" # Pass the seeds so the queried symbol renders first and survives truncation diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py index c75c366a22..802ebcde3b 100644 --- a/tests/test_serve_seeding.py +++ b/tests/test_serve_seeding.py @@ -105,3 +105,144 @@ def test_scorer_and_picker_are_unchanged_for_direct_callers(): ) seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) assert DECOY in seeds, "demotion leaked into _pick_seeds' semantics" + + +# --------------------------------------------------------------------------- # +# C3 — heuristic-context-filter starvation fallback (#42) # +# # +# A class node has no call-context edges of its own: calls attach to its # +# methods, and the class->member edge carries `context=None`. So the `call` # +# filter that "Who calls X?" infers strands a perfectly-seeded class seed at # +# exactly one node. When an *inferred* filter discovers nothing beyond the # +# seeds, the query retraverses unfiltered and says so in the header; an # +# *explicit* filter is always honored. Assertions read the header and the # +# rendered NODE lines only. # +# --------------------------------------------------------------------------- # + +from tests.seeding_fixtures import CALLERS, SERVICE_METHOD # noqa: E402 + + +def context_segment(text: str) -> str: + """The `Context: ...` segment of a `_query_graph_text` header, or "" if the + query ran unfiltered. Read as a whole so the source and any relaxation note + are asserted where the caller actually sees them.""" + for part in text.split("\n", 1)[0].split(" | "): + if part.startswith("Context:"): + return part + return "" + + +def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): + """The measured phrasing A end-to-end: "Who calls ChargeCustomerService?" + + The inferred `call` filter leaves the class seed with nowhere to go, so the + traversal relaxes and every known caller renders — including the one + reachable only through its `references` edge (#38's extraction gap). The + header keeps the failure mode visible: the heuristic context is still + reported, annotated as relaxed, so a fallback answer never reads as a + filtered one. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + context = context_segment(text) + + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds, f"verb-prefix decoy seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + assert label_of(DECOY) not in shown, f"decoy neighborhood leaked in:\n{text}" + assert "heuristic" in context, f"header lost the inferred context: {context!r}" + assert "relaxed" in context, f"header does not report the relaxation: {context!r}" + + +def test_expanding_heuristic_filter_is_left_in_force(): + """A heuristic filter that does reach past the seeds is not second-guessed. + + Seeded on the *method* node, the same inferred `call` filter finds the two + real call edges, so no fallback fires: the header carries no relaxation note + and the `references`-only caller stays filtered out — which is what proves + the filter is still doing its job rather than having been dropped. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "Who calls ChargeCustomerService.charge?", mode="bfs", depth=2) + shown = shown_nodes(text) + context = context_segment(text) + + assert "heuristic" in context, f"header lost the inferred context: {context!r}" + assert "relax" not in context, f"expanding filter was needlessly relaxed: {context!r}" + assert label_of(CALLERS[0]) in shown + assert label_of(CALLERS[1]) in shown + assert label_of(CALLERS[2]) not in shown, ( + f"filter no longer in force — the import-edge caller leaked in:\n{text}" + ) + + +def test_explicit_context_filter_never_falls_back_even_when_stranded(): + """The identical stranding, with the filter passed explicitly: honored. + + An explicit instruction is never overridden, so the answer stays at the seed + alone and the header reports an unqualified explicit filter. + """ + G = make_charge_fixture() + text = _query_graph_text( + G, "Who calls ChargeCustomerService?", mode="bfs", depth=2, + context_filters=["call"], + ) + shown = shown_nodes(text) + context = context_segment(text) + + assert shown == [label_of(SERVICE)], f"explicit filter was relaxed:\n{text}" + assert "explicit" in context, f"header lost the explicit source: {context!r}" + assert "relax" not in context, f"explicit filter was annotated as relaxed: {context!r}" + + +def test_starvation_fallback_is_identical_in_both_traversal_modes(): + """Mode choice must not change filter behavior: DFS relaxes exactly where + BFS does, and reaches the same nodes.""" + G = make_charge_fixture() + question = "Who calls ChargeCustomerService?" + bfs = _query_graph_text(G, question, mode="bfs", depth=2) + dfs = _query_graph_text(G, question, mode="dfs", depth=2) + + for mode, text in (("BFS", bfs), ("DFS", dfs)): + context = context_segment(text) + assert "relaxed" in context, f"{mode} did not relax: {context!r}" + for caller in caller_labels(): + assert caller in shown_nodes(text), ( + f"{mode} missing caller {caller!r}:\n{text}" + ) + assert set(shown_nodes(bfs)) == set(shown_nodes(dfs)), ( + "traversal modes disagree under the fallback:\n" + f"BFS={shown_nodes(bfs)}\nDFS={shown_nodes(dfs)}" + ) + + +def test_single_node_expansion_is_not_starvation(): + """The threshold is *zero* expansion, not "few nodes" — one discovered node + is enough to leave the filter alone. + + Local tweak: drop one of the service method's two call edges, so the + heuristic `call` filter reaches exactly one node beyond the seeds. Pinning + this boundary is what stops the threshold from drifting into a tuning + constant (`<= len(seeds) + 1` and friends): such an implementation relaxes + here, which this test rejects. Asserted for both modes, since the threshold + is shared. + """ + for mode in ("bfs", "dfs"): + G = make_charge_fixture() + G.remove_edge(CALLERS[1], SERVICE_METHOD) + text = _query_graph_text( + G, "Who calls ChargeCustomerService.charge?", mode=mode, depth=2 + ) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert set(shown) == set(seeds) | {label_of(CALLERS[0])}, ( + f"{mode}: expected exactly one node beyond the seeds:\n{text}" + ) + assert "relax" not in context_segment(text), ( + f"{mode}: a filter that discovered a node was relaxed anyway — the " + f"threshold is no longer zero expansion:\n{text}" + ) From 635db73f527511887a4c0d5bdb709c30ae55739c Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 14:24:18 -0300 Subject: [PATCH 28/41] fix(serve): judge covered-term coverage on the seed label, never its node id (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch on cebf231. The coverage predicate reused `_seed_label_key`, whose `or nid` tail fires even with `G` non-None whenever a seed's label normalizes to empty. That tail is correct for the dedup gate — a labelless node still needs something unique to dedup on — but wrong for coverage. `norm_label == ""` nodes are real: build.py's `_fold_node_aliases` documents an alias-only node entering the graph with no label, and such a node is still seedable because _score_nodes' source-file tier (serve.py:565) scores it. Via the tail, a ghost seeded from `src/customer_utils.py::helper` declared the term "customer" covered through a node-id substring — a match the scorer never makes, since its substring tier (serve.py:561) reads `norm_label`, built at :500 with no nid fallback. The term's real winner (`CustomerModel`) was then starved, breaking the very invariant the flag is supposed to refine. Add `_seed_norm_label`, which returns the normalized label or "" and is used by the coverage check alone; `_seed_label_key` and the dedup gate are unchanged. A seed with no label now covers nothing. Red-first regression test asserts the labelless seed's term winner still gets its guaranteed seed under `skip_covered_terms=True`, and grounds both premises first (the winner is the labelled node; the ghost really does score). Refs #37, #41 --- graphify/serve.py | 26 +++++++++++++++++++---- tests/test_serve_seeding.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/graphify/serve.py b/graphify/serve.py index e919ec747d..fc6aaf1b3c 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -673,9 +673,10 @@ def _pick_seeds( refines that guarantee to *every term with any match is matched by at least one seed*, from *every term's own singleton winner gets a slot*: a term that is a substring of an already-picked seed's normalized label — the same - predicate as _score_nodes' weakest match tier, so the seed would have - matched the term in scoring — is not starved, and starvation is the only - thing the guarantee exists to prevent. Without it, a natural-language + predicate as _score_nodes' weakest match tier, and its label alone, so the + seed provably would have matched the term in scoring — is not starved, and + starvation is the only thing the guarantee exists to prevent. A seed with no + label covers nothing. Without this skip, a natural-language question's generic nouns each drag in their own hub: "what code uses ChargeCustomerService to charge a customer" seeds the `Customer` model and `.charge()` on top of `ChargeCustomerService`, whose label already matches @@ -698,6 +699,23 @@ def _seed_label_key(nid: str) -> str: return (data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()) or nid + # The `skip_covered_terms` coverage predicate needs the seed's normalized + # LABEL alone — empty when the node has none — not `_seed_label_key`. That + # key's `or nid` tail is correct for the dedup gate above (a labelless node + # still needs something unique to dedup on) but wrong for coverage: a + # `norm_label == ""` node is real (build.py's `_fold_node_aliases`: an + # alias-only node enters the graph with no label) and is still seedable + # through _score_nodes' source-file tier, so the tail would let its node-id + # path fragments declare unrelated terms covered — a match _score_nodes never + # makes, since its substring tier reads `norm_label` only. That would starve + # the term's real winner and break the very invariant this flag refines. + def _seed_norm_label(nid: str) -> str: + if G is None: + return "" + data = G.nodes[nid] + return (data.get("norm_label") + or _strip_diacritics(data.get("label") or "").lower()) + top_score = scored[0][0] seeds: list[str] = [] seen_labels: set[str] = set() @@ -734,7 +752,7 @@ def _seed_label_key(nid: str) -> str: # Layered after that dedup gate, in this same sorted-term order, so # the coverage check sees the gap-window seeds plus every guarantee # seed appended so far. - if skip_covered_terms and any(term in _seed_label_key(s) for s in seeds): + if skip_covered_terms and any(term in _seed_norm_label(s) for s in seeds): continue seen_labels.add(key) seeds.append(best_nid) diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py index 64a707cc99..c274c8892f 100644 --- a/tests/test_serve_seeding.py +++ b/tests/test_serve_seeding.py @@ -120,6 +120,8 @@ def test_scorer_and_picker_are_unchanged_for_direct_callers(): # NODE lines, the header's node count, and the seed lists the prior seeding # tests already assert on. +import networkx as nx # noqa: E402 + from graphify.serve import _bfs, _demote_relational_intent_terms # noqa: E402 from tests.seeding_fixtures import DOC, HUB # noqa: E402 @@ -223,3 +225,43 @@ def test_covered_term_skip_is_off_unless_the_caller_opts_in(): ) assert HUB in legacy, f"the covered-term skip leaked into the default picker: {legacy}" assert DOC in legacy + + +def test_coverage_is_judged_on_the_seed_label_never_its_node_id(): + """A labelless seed must not declare a term covered through its node id. + + `norm_label == ""` ghost nodes are real (see `_fold_node_aliases` in + build.py: an alias-only node enters the graph with no label) and they can + still be seeds, because the source-file tier scores them. The dedup gate + falls back to the node id for such a node — it has to, since a labelless + node needs *something* unique to dedup on — but the coverage predicate must + not, or the ghost's path fragments silently cover unrelated terms and starve + their winners. `_score_nodes`' substring tier reads `norm_label` only, so a + seed with no label covers nothing. + """ + ghost = "src/customer_utils.py::helper" + G = nx.Graph() + G.add_node(ghost, label="", source_file="src/customer_utils.py") + G.add_node("cm", label="CustomerModel", source_file="app/Models/CustomerModel.php") + + qs = _score_query(G, ["customer"], collect_per_term_seeds=True) + # Premise 1: the term's real winner is the labelled node. + assert qs.best_seed_by_term["customer"] == "cm" + # Premise 2: the labelless ghost still scores — via the source-file tier — + # so it is a legitimate seed candidate rather than a hypothetical. + assert dict((nid, s) for s, nid in qs.ranked)[ghost] > 0 + + # Force the ghost to be the only gap-window seed, the way an unrelated exact + # match does on a real corpus (the #1445 shape): `cm` can now enter only + # through the per-term guarantee. + seeds = _pick_seeds( + [(1000.0, ghost), (1.0, "cm")], + G=G, + best_seed_by_term=qs.best_seed_by_term, + skip_covered_terms=True, + ) + assert ghost in seeds + assert "cm" in seeds, ( + "'customer' was treated as covered by a seed with no label — coverage read " + f"the node id, so the term's winner was starved: {seeds}" + ) From 457b31e84221552bfbcfa5271d59c3fb258fea64 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 14:40:38 -0300 Subject: [PATCH 29/41] chore: bump to 0.9.36; changelog for the query seeding fix (#40/#41/#42/#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases the three serve-side seeding changes behind fork #37 (upstream issue 2507): relational-intent demotion from the per-term seed guarantee (#40), the covered-term guarantee skip (#41), and the heuristic context-filter starvation fallback with an annotated header (#42). Serve-side only — no extraction, merge or graph-format change, so no rebuild is needed to pick it up. uv.lock records the new project version; it also had been lagging at 0.9.34 since the upstream v0.9.35 sync merge bumped pyproject.toml alone. Refs #43 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9b76e6fa..5ac6b07aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.36 (unreleased) + +- Fix: a relational word in a natural-language `graphify query` — "who **calls** X", "**callers** of X", "what **uses** X" — no longer buys an unrelated decoy a guaranteed seed slot (`lawnstarter/graphify#37`, upstream #2507). Such a word names the *relation* the question asks about, not a symbol to start from, but the per-term seed guarantee (#1445) hands every query term an unconditional seat that bypasses the gap cutoff — so a `callStoreWithAmount()` test helper, which prefix-matches "calls", entered the traversal beside the real identifier and spent the whole token budget on its test neighborhood. Relational words now forfeit that unconditional seat, **and nothing else**: they are NOT stopworded (upstream deliberately pinned "calls" as a kept content term in #1597), they still score, and they still win seeds through the gap window on merit, so a corpus symbol literally named `calls` or `uses` keeps its exact-match dominance and stays seedable — as it always was through `explain`, `path` and `find_node`, none of which share this code path. The vocabulary is derived from the two tables that already encode relational intent (`_CONTEXT_HINTS` words plus `_CONTEXT_FILTER_ALIASES` keys), extended with the caller/use/listen families because "callers" and "uses" appear in neither table yet carry the same intent; the hint table itself is untouched, so which queries get context-filtered is unchanged. A query made of nothing *but* relational words (`graphify query "calls"`) keeps its guarantee unfiltered — it has no identifier to seed from — mirroring the existing all-stopword fallback in query-term extraction. +- Fix: a query term that an already-picked seed's label plainly matches no longer claims a second, redundant guaranteed seed (`lawnstarter/graphify#37`). The #1445 guarantee exists to stop one term's incidental exact match from starving the other terms, and that protection is unchanged for a genuinely starved term; what changes is the invariant it enforces, from *every term's own singleton winner gets a slot* to *every term with any match is matched by at least one seed*. A term that is a substring of a picked seed's normalized label — the same predicate as the scorer's weakest match tier, so the seed provably would have matched the term in scoring — is by definition not starved. Without the skip, each generic noun in a question dragged in its own hub: "what code uses ChargeCustomerService to charge a customer" seeded the `Customer` model and a `.charge()` method on top of `ChargeCustomerService`, whose label already matches both terms, and the hub's member fan-out then flooded the traversal (a measured 2,307-node traversal on a 48.5k-node corpus). Coverage is judged on the seed's normalized **label** alone and never on its node id: a labelless node is real (an alias-only node enters the graph with no label) and is still seedable through the source-file scoring tier, so reading its id's path fragments would declare unrelated terms covered on a match the scorer never made — and starve the term's real winner, breaking the very invariant this refines. The check runs after the existing homonymous-label dedup gate (#1766) and in the same sorted-term order, so a generic label still contributes at most one seed. +- Fix: a *heuristically inferred* context filter that reaches nothing beyond the seeds now relaxes to an unfiltered traversal instead of returning a confident near-empty answer (`lawnstarter/graphify#37`). "Who calls ChargeCustomerService?" infers a `call` filter, but a class node owns no call edges — calls attach to its methods, and the class-to-member edge carries no context — so the filtered traversal could not leave the seed even with perfect seeding. The threshold is deliberately zero expansion (the traversal discovered nothing at all, which needs no tuning constant; a filter that found *something* is doing its job), it applies identically in breadth-first and depth-first mode, and the header says so — `Context: call (heuristic; relaxed — no matches beyond seeds)` — so a relaxed answer never reads as a filtered one. An **explicitly** requested `--context` filter is an instruction, not a guess, and is honored even when it strands the seeds. +- Serve-side only: these three changes are opt-ins wired exclusively from the natural-language query pipeline, so the scorer stays byte-identical (its legacy-equality property tests and the query-scoring benchmark's equality gate hold without edits) and every other caller of the seed picker is untouched by construction. Nothing in extraction, merge or the graph format changes, so no rebuild or re-extraction is needed to pick this up — though the package version bump does roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does, which for this release only means the next file dispatched for extraction is re-parsed rather than served from cache. +- Together these three take the motivating impact questions from wrong-but-confident to correct on a real 48.5k-node Laravel corpus, where the queried service has exactly 3 grep-verified production callers: "Who calls ChargeCustomerService?" rendered 0 of the 3 (a budget's worth of the decoy's test neighborhood instead), "callers of ChargeCustomerService" found all 3 but buried them in an unrelated seed's haystack, and "what code uses ChargeCustomerService to charge a customer" rendered 0 of 3 out of a multi-thousand-node traversal explosion; all three now render all 3 callers within the default token budget. One residual is documented rather than chased: a generic noun like "code" can still guarantee a seed for a doc file that merely prefix-matches it (a `coder.md`), contributing a small minority of the traversal — below every acceptance bar, and complementary upstream work on test-path seed exclusion (#2381) and bounded per-term recovery (#2113) would shrink it further. + ## 0.9.35 (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`. diff --git a/pyproject.toml b/pyproject.toml index 6288b9c8a0..f99f81e80f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.35" +version = "0.9.36" 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 c737c43bf0..89224ec35a 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.34" +version = "0.9.36" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From acf29e13178e541ec731e8e07acd6a5822d138a9 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 15:52:54 -0300 Subject: [PATCH 30/41] fix(serve): only the top-ranked seed may declare a term covered (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the verified review finding on upstream PR Graphify-Labs#2516 (fixed upstream as 234c5f0) onto the fork's sync branch, per the maintainer's rule that everything landing upstream lands here too. The defect, in the fork's C2 (#37) as shipped in 0.9.36 and carried through the 0.9.35 sync: `skip_covered_terms` let ANY already-picked seed absorb a term whose letters its normalized label happened to contain. That reintroduces the very starvation the #1445 guarantee exists to prevent, one layer down — a coincidental substring collision inside an unrelated, lower-ranked seed costs a distinct term its only seat. Measured on the regression fixture: a node labelled exactly `port` scores 7,624.6 against `ReportService`'s 1.04 in `_score_query` — 7,333x, the term's unambiguous singleton winner — and still lost its guaranteed seat, because `ReportService` contains the letters "port" and had cleared the gap window. For a term whose winner sits outside that window, the guarantee is the only way in, so the seat is not a nicety. Coverage asserts "the query's dominant match already answers this term", which is a statement about the query's subject and a claim only the top-ranked seed is entitled to make. The predicate is now `seeds and term in _seed_norm_label(seeds[0])` — `seeds[0]` is always `scored[0]`, the dominant match, and is always already present when the guarantee loop runs; the `seeds` guard keeps the empty case safe. Invariant restated in the `_pick_seeds` docstring, from *every term with any match is matched by at least one seed* to *every term with any match is matched by the top-ranked seed or by a seed of its own*. Nothing else about the skip changes: same normalized-LABEL predicate and never the node id, a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. Red-first. The new regression test was ported and run BEFORE the production change, on be80845: FAILED tests/test_serve_seeding.py::test_only_the_top_ranked_seed_can_declare_a_term_covered AssertionError: 'port' was declared covered by `ReportService`, a lower-ranked seed that merely contains the letters — the term's real winner was starved: ['boot', 'report'] assert 'cfg' in ['boot', 'report'] 1 failed in 0.22s Green after. Every ADDED line of the serve.py refinement is identical to upstream 234c5f0's, modulo issue references adapted to fork context (#37/C2 for the behaviour, Graphify-Labs#2516 review for the refinement rationale); verified by diffing the two patches with the refs normalized. The 0.9.36 changelog section is NOT edited — it describes shipped 0.9.36 behaviour, and this is a 0.9.37 refinement of it. Upstream's changelog wording correction is carried as a new bullet in the 0.9.37 sync section instead; the 0.9.36 section is byte-identical to fork v8 (`cmp` against 2033c46:CHANGELOG.md). Suite: 4212 passed, 36 skipped, 4 failed — the same 4 pre-existing `tests/test_ollama_retry_cap.py` env failures (`No module named 'openai'`), +1 passing from the new regression test. Bench equality gate exit 0; skillgen --check and ruff clean. Both mutation probes re-run on the REFINED code and still caught: - C3 `+1` threshold mutant -> test_single_node_expansion_is_not_starvation and upstream's test_query_graph_text_heuristic_context_filter_changes_traversal - C2 label-vs-nid mutant (`or nid` restored to `_seed_norm_label`) -> test_coverage_is_judged_on_the_seed_label_never_its_node_id The second was the one at risk: narrowing coverage to `seeds[0]` could have made the ghost-label test vacuous. It does not — that test forces the labelless ghost to be the ONLY gap-window seed, so the ghost IS `seeds[0]`, the one seed entitled to declare coverage. Confirmed by execution, not by argument; the test's docstring now records why it stays load-bearing. Corpus spot-check, graphifyy 0.9.37 reinstalled from this worktree against the 48.5k-node Laravel graph: all three impact phrasings and `explain "CustomerWasCharged"` are BYTE-IDENTICAL to the pre-refinement run (6,474 / 6,474 / 6,411 / 1,671 chars), 128 / 128 / 138 nodes, 3-of-3 callers each. Expected: on every graded phrasing the covering seed is the top seed — `chargecustomerservice` covers "chargecustomerservice", "charge" and "customer" — so narrowing the rule to `seeds[0]` cannot change them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + graphify/serve.py | 44 +++++++++++++++++++++----------- tests/test_serve_seeding.py | 50 ++++++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6a709d1c..5ccecb974b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Sync: upstream `Graphify-Labs/graphify` **0.9.35** is absorbed into the fork (upstream `9f25a3a`, tag `v0.9.35`). Its `build_merge` shrink-guard and absolute-path prune fixes (#2497, #2446), the `graphify update` newly-ignored-file eviction and Java local-class/external-annotation split (#2495, #2504), and the direction-aware `graphify callflow` (#2508) are taken wholesale — the fork had no competing edits there. Every upstream 0.9.35 bullet is reproduced verbatim in its own section below. - Sync: upstream's own take on the relational-verb seeding defect (#2507, filed from this fork) collided with the fork's `lawnstarter/graphify#37` C1 fix in `graphify/serve.py`; the fork's structure wins and upstream's vocabulary is absorbed into it, so no demotion either side shipped is lost. Upstream demotes a hardcoded verbs-only list inline at the `_query_graph_text` call site; the fork derives `_RELATIONAL_INTENT_TERMS` from the two tables that already encode relational intent and demotes through the `_demote_relational_intent_terms` helper, which keeps the map-keyed all-relational fallback (`graphify query "calls"` keeps its guarantee) and composes with the fork's `skip_covered_terms` (#37/C2) and heuristic-filter relaxation (#37/C3). The derived set now unions in the twelve words upstream demoted that neither table yields — `depend`, `depends`, `export`, `extend`, `extends`, `extended`, `implement`, `implements`, `implemented`, `reference`, `references`, `referenced` — making the fork's vocabulary a strict superset of both parents' (62 words vs upstream's 29). Upstream's five #2507 tests are adopted verbatim and pass against the fork's implementation unchanged. - Behavior change: **`caller` and `callers` now infer the `call` context filter**, adopting upstream's `_CONTEXT_HINTS` addition. This supersedes the "the hint table itself is untouched, so which queries get context-filtered is unchanged" note in 0.9.36 below: "callers of X" now takes the filtered path that "who calls X" already took. On a bare class node that is exactly the stranding case #37/C3 was built for — a class owns no `call` edges — so the filter reaches nothing beyond the seeds, the traversal relaxes to unfiltered, and the header says `Context: call (heuristic; relaxed — no matches beyond seeds)`. The answer is unchanged in content (all 3 callers still render on the 48.5k-node Laravel corpus); only the header note is new. Upstream shipped this hint without the relaxation fallback, so on upstream the same query strands; on the fork it does not. +- Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip 0.9.36 shipped (`lawnstarter/graphify#37` C2; adopted from the `Graphify-Labs#2516` review, upstream `234c5f0`). 0.9.36 let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. - The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. ## 0.9.36 (unreleased) diff --git a/graphify/serve.py b/graphify/serve.py index be9f9fcb54..82ff9aecff 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -670,17 +670,26 @@ def _pick_seeds( scores a dampened collision can still exceed. `skip_covered_terms` (default off, opted into only by `_query_graph_text`) - refines that guarantee to *every term with any match is matched by at least - one seed*, from *every term's own singleton winner gets a slot*: a term that - is a substring of an already-picked seed's normalized label — the same - predicate as _score_nodes' weakest match tier, and its label alone, so the - seed provably would have matched the term in scoring — is not starved, and - starvation is the only thing the guarantee exists to prevent. A seed with no - label covers nothing. Without this skip, a natural-language - question's generic nouns each drag in their own hub: "what code uses - ChargeCustomerService to charge a customer" seeds the `Customer` model and - `.charge()` on top of `ChargeCustomerService`, whose label already matches - both terms, and the hub's member fan-out then floods the traversal (#37/C2). + refines that guarantee to *every term with any match is matched by the + top-ranked seed or by a seed of its own*, from *every term's own singleton + winner gets a slot*: a term that is a substring of the top-ranked seed's + normalized label — the same predicate as _score_nodes' weakest match tier, + and its label alone, so that seed provably would have matched the term in + scoring — is not starved, and starvation is the only thing the guarantee + exists to prevent. A seed with no label covers nothing. Without this skip, a + natural-language question's generic nouns each drag in their own hub: "what + code uses ChargeCustomerService to charge a customer" seeds the `Customer` + model and `.charge()` on top of `ChargeCustomerService`, whose label already + matches both terms, and the hub's member fan-out then floods the traversal + (#37/C2). + + Only the TOP-ranked seed may make that claim, not any picked seed. Coverage + asserts "the query's dominant match already answers this term", which is a + statement about the query's subject; a lower-ranked seed that merely happens + to contain the term's letters (`ReportService` for "port") is asserting a + coincidence, and letting it skip the guarantee starves the term's real + winner — for a term whose winner sits outside the gap window, the guarantee + is the only way in (Graphify-Labs#2516 review). """ if not scored: return [] @@ -749,10 +758,15 @@ def _seed_norm_label(nid: str) -> str: key = _seed_label_key(best_nid) if best_nid in seeds or key in seen_labels: continue - # Layered after that dedup gate, in this same sorted-term order, so - # the coverage check sees the gap-window seeds plus every guarantee - # seed appended so far. - if skip_covered_terms and any(term in _seed_norm_label(s) for s in seeds): + # Layered after that dedup gate, but only the TOP-ranked seed — the + # query's dominant match, always `scored[0]` and so always already + # present here — can declare a term covered. Letting any picked seed + # make that claim lets a coincidental substring collision inside an + # unrelated, lower-ranked seed's label silently starve a distinct + # term's real winner: `ReportService` contains "port", which would + # cost a corpus symbol literally named `port` the only seat it can + # get (Graphify-Labs#2516 review; the #1597 concern one layer down). + if skip_covered_terms and seeds and term in _seed_norm_label(seeds[0]): continue seen_labels.add(key) seeds.append(best_nid) diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py index 0a80bb31b8..aeb4d159db 100644 --- a/tests/test_serve_seeding.py +++ b/tests/test_serve_seeding.py @@ -211,13 +211,56 @@ def test_covered_term_skips_guarantee_while_starved_term_is_still_recovered(): assert SERVICE in seeds assert HUB not in seeds, ( - "'customer' is a substring of the picked seed's label, so it is not starved" + "'customer' is a substring of the top-ranked seed's label, so it is not starved" ) assert DOC in seeds, ( "'code' is matched by no picked seed's label — the #1445 guarantee must still fire" ) +def test_only_the_top_ranked_seed_can_declare_a_term_covered(): + """A substring collision inside a *lower-ranked* seed must not starve a term. + + Coverage means "the query's dominant match already answers this term", which + is a claim only the top-ranked seed is entitled to make. Let any picked seed + make it and an unrelated one absorbs a term by coincidence: `ReportService` + contains the letters of "port", so a corpus symbol literally named `port` + loses the guaranteed seat that is the only way it can enter this seed list — + the #1597 concern (a corpus may legitimately name a symbol after a common + word) reappearing one layer down. Refinement of #37/C2, adopted from the + Graphify-Labs#2516 review. + + Ranks are supplied directly rather than scored, the same way + `test_coverage_is_judged_on_the_seed_label_never_its_node_id` does: the point + is a specific gap-window shape (a dominant match, an unrelated runner-up that + happens to contain the term, and the term's own winner below the cutoff), and + naming it beats coaxing a synthetic corpus into producing it. + """ + G = nx.Graph() + G.add_node("cfg", label="port", source_file="src/config/server.py") + G.add_node("report", label="ReportService", source_file="app/Reports/ReportService.php") + G.add_node("boot", label="ServerBootstrap", source_file="src/server_bootstrap.py") + + qs = _score_query(G, ["port"], collect_per_term_seeds=True) + # Premise: the term's winner is the exact-match node, by ~7300x — `port` is a + # real symbol here, not a coincidence, and `ReportService` is the coincidence. + assert qs.best_seed_by_term["port"] == "cfg" + + scored = [(1000.0, "boot"), (300.0, "report"), (1.0, "cfg")] + seeds = _pick_seeds( + scored, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=True + ) + + # Premise: both of the unrelated nodes clear the gap window, and the term's + # own winner does not — so the guarantee is its only way in. + assert seeds[0] == "boot", f"top-ranked seed is not the dominant match: {seeds}" + assert "report" in seeds + assert "cfg" in seeds, ( + "'port' was declared covered by `ReportService`, a lower-ranked seed that " + f"merely contains the letters — the term's real winner was starved: {seeds}" + ) + + def test_covered_term_skip_is_off_unless_the_caller_opts_in(): """Default-off: identical results for every caller that does not opt in. @@ -249,6 +292,11 @@ def test_coverage_is_judged_on_the_seed_label_never_its_node_id(): not, or the ghost's path fragments silently cover unrelated terms and starve their winners. `_score_nodes`' substring tier reads `norm_label` only, so a seed with no label covers nothing. + + Still load-bearing under the top-ranked-seed-only rule: the ghost is forced + to be the *only* gap-window seed below, so it IS `seeds[0]` — the one seed + entitled to declare coverage. Narrowing which seed may cover does not narrow + what this test traps. """ ghost = "src/customer_utils.py::helper" G = nx.Graph() From 075038ee1d170a13aecd0cbdac0d90ebd20eb46b Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Fri, 7 Aug 2026 10:13:21 -0300 Subject: [PATCH 31/41] fix(engine): refuse relative-scope names as PHP scoped-call callees (#39) The scoped_call_expression handler took the scope text as the callee name, so parent::setUp() minted a raw call to a callee literally named 'parent'. Unresolved in-file, that reached the cross-file pass, which matches by normalized label and bound it to any unrelated ->parent() method in the corpus (1,698 wrong inbound edges on ServiceCategory::parent() at api scale). parent/self/static are relative scopes: which class they denote needs the inheritance context the raw-call facts do not carry, so refuse rather than guess. Gated on _PHP_NON_CONCRETE_TYPE_NAMES, the same set the (new X())->m() branch already applies. Absolute scopes are unaffected. Co-Authored-By: Claude Opus 5 --- graphify/extractors/engine.py | 11 ++- tests/test_php_relative_scope_calls.py | 121 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/test_php_relative_scope_calls.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 91bff2bf75..21cae54ae4 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4808,7 +4808,16 @@ def walk_calls( # Static method call: Helper::format() → callee = "Helper" scope_node = node.child_by_field_name("scope") if scope_node: - callee_name = _read_text(scope_node, source) + scope_text = _read_text(scope_node, source) + # `parent::m()` / `self::m()` / `static::m()` name no + # callee: which class the scope denotes needs inheritance + # context the raw-call facts do not carry (#39). Naming + # the scope anyway let the cross-file label match bind + # them to any unrelated `->parent()` method in the + # corpus. Refused by the same non-concrete set the + # `(new X())->m()` branch below applies. + if scope_text.lower() not in _PHP_NON_CONCRETE_TYPE_NAMES: + callee_name = scope_text else: # member_call_expression / nullsafe_member_call_expression: # $obj->method() / $obj?->method() diff --git a/tests/test_php_relative_scope_calls.py b/tests/test_php_relative_scope_calls.py new file mode 100644 index 0000000000..bc3eb9dd39 --- /dev/null +++ b/tests/test_php_relative_scope_calls.py @@ -0,0 +1,121 @@ +"""PHP relative-scope calls must not name a callee (#39). + +``parent::setUp()`` / ``self::foo()`` / ``static::bar()`` are *relative* scopes: +which class they denote depends on inheritance context the raw-call facts do not +carry. The ``scoped_call_expression`` handler used to take the scope text as the +callee name, so ``parent::setUp()`` produced a raw call to a callee literally +named ``parent`` — which the cross-file pass then matched, by normalized label, +against any unrelated ``->parent()`` method in the corpus. + +Refusal over guessing: the relative scopes name no callee at all. Absolute +scopes (``Helper::format()``) are unaffected. +""" +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: + nid = next( + ( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ), + None, + ) + assert nid is not None, f"no node {label}/{id_contains}" + return nid + + +def _relative_scope_corpus(scope: str, callee: str) -> dict[str, str]: + """A caller using ``::()`` plus an unrelated decoy method. + + The decoy is named after the SCOPE (``parent`` / ``self`` / ``static``) — + that is the label the buggy callee name matched against corpus-wide. + """ + return { + "app/A.php": ( + " Date: Fri, 7 Aug 2026 10:17:57 -0300 Subject: [PATCH 32/41] fix(serve): find_node_ambiguity must not collapse sourceless rivals into one group (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_node_ambiguity` grouped the winning match tier by `source_file`. Every stub the extractor mints for a reference it could not resolve carries `source_file == ""`, so N unrelated stubs collapsed into a single `""` bucket and looked like N members of one file: no ambiguity was reported, and `explain` answered with `matches[0]` — whichever stub `G.nodes()` yielded first. On the pinned corpus that is the 18 stubs shadowing `BalanceitemRepository`; reorder the graph and the same query answered with a different stub, equally confidently, while `affected` refused with "No unique node match" (RC3 of #46). - `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one. A stub is a broken duplicate of the real declaration, never the better answer, and not something the caller could disambiguate anyway — it has no path to retry with. - Sourceless nodes are keyed individually in the ambiguity grouping, so a tier made only of stubs reports rivals instead of picking one silently. Neither change depends on stub counts staying high. - `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in its exact-label and bare-name passes, so `explain` and `affected` now agree: both resolve to the sourced declaration when one exists, both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/affected.py | 31 +++- graphify/serve.py | 26 ++- tests/test_stub_shadow_ambiguity.py | 243 ++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 tests/test_stub_shadow_ambiguity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ccecb974b..40ad8d75b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Sync: upstream's own take on the relational-verb seeding defect (#2507, filed from this fork) collided with the fork's `lawnstarter/graphify#37` C1 fix in `graphify/serve.py`; the fork's structure wins and upstream's vocabulary is absorbed into it, so no demotion either side shipped is lost. Upstream demotes a hardcoded verbs-only list inline at the `_query_graph_text` call site; the fork derives `_RELATIONAL_INTENT_TERMS` from the two tables that already encode relational intent and demotes through the `_demote_relational_intent_terms` helper, which keeps the map-keyed all-relational fallback (`graphify query "calls"` keeps its guarantee) and composes with the fork's `skip_covered_terms` (#37/C2) and heuristic-filter relaxation (#37/C3). The derived set now unions in the twelve words upstream demoted that neither table yields — `depend`, `depends`, `export`, `extend`, `extends`, `extended`, `implement`, `implements`, `implemented`, `reference`, `references`, `referenced` — making the fork's vocabulary a strict superset of both parents' (62 words vs upstream's 29). Upstream's five #2507 tests are adopted verbatim and pass against the fork's implementation unchanged. - Behavior change: **`caller` and `callers` now infer the `call` context filter**, adopting upstream's `_CONTEXT_HINTS` addition. This supersedes the "the hint table itself is untouched, so which queries get context-filtered is unchanged" note in 0.9.36 below: "callers of X" now takes the filtered path that "who calls X" already took. On a bare class node that is exactly the stranding case #37/C3 was built for — a class owns no `call` edges — so the filter reaches nothing beyond the seeds, the traversal relaxes to unfiltered, and the header says `Context: call (heuristic; relaxed — no matches beyond seeds)`. The answer is unchanged in content (all 3 callers still render on the 48.5k-node Laravel corpus); only the header note is new. Upstream shipped this hint without the relaxation fallback, so on upstream the same query strands; on the fork it does not. - Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip 0.9.36 shipped (`lawnstarter/graphify#37` C2; adopted from the `Graphify-Labs#2516` review, upstream `234c5f0`). 0.9.36 let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. +- Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. - The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. ## 0.9.36 (unreleased) diff --git a/graphify/affected.py b/graphify/affected.py index ce14152236..371055667c 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -96,6 +96,22 @@ def _prefer_file_node( return None +def _is_sourced(graph: nx.Graph, node_id: str) -> bool: + return bool(str(graph.nodes[node_id].get("source_file") or "")) + + +def _prefer_sourced_node(graph: nx.Graph, node_ids: list[str]) -> str | None: + """Return the one node with a source file among label rivals, else None. + + Mirrors serve's `_find_node_tiers` exact-tier rule (#49): a sourceless node is + an unresolved-reference placeholder, so a real declaration sharing its label is + the answer rather than a tie. Without this, `explain` resolved the sourced node + while `affected` refused with "No unique node match" — #46's symptom. + """ + sourced = [node_id for node_id in node_ids if _is_sourced(graph, node_id)] + return sourced[0] if len(sourced) == 1 else None + + def resolve_seed(graph: nx.Graph, query: str) -> str | None: # A trailing path separator must not change a source-file match — serve's # _find_node tokenizes the path (which drops it), so strip it here for parity @@ -109,8 +125,17 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None: for node_id, data in graph.nodes(data=True) if _normalize_label(str(data.get("label", ""))) == query_lower ] - if len(exact_label_matches) == 1: + if len(exact_label_matches) == 1 and _is_sourced(graph, exact_label_matches[0]): return exact_label_matches[0] + if exact_label_matches: + sourced = _prefer_sourced_node(graph, exact_label_matches) + if sourced is not None: + return sourced + # A lone match that is a sourceless stub is NOT yet an answer: the real + # declaration may carry a decorated label ("handle()") that only the + # bare-name pass below reaches, and serve's exact tier — which matches + # both forms — would prefer it (#49). That pass is a superset of this + # one, so a stub with no sourced rival still resolves there. # Callable labels are decorated ("name()"), so a bare "name" query falls # through exact matching and then ties with any "name*" sibling in the # contains pass. Match on the undecorated name before giving up. @@ -122,6 +147,10 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None: ] if len(bare_name_matches) == 1: return bare_name_matches[0] + if bare_name_matches: + sourced = _prefer_sourced_node(graph, bare_name_matches) + if sourced is not None: + return sourced exact_source_matches = [ str(node_id) for node_id, data in graph.nodes(data=True) diff --git a/graphify/serve.py b/graphify/serve.py index 82ff9aecff..4726a523ed 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1335,6 +1335,16 @@ def _find_node_tiers( elif term in norm_label or term in label_tokens or norm_query in norm_label: substring.append(nid) + # A sourceless node is a placeholder the extractor minted for a reference it + # could not resolve (an unresolved base type, a dangling import target). When + # a real, sourced declaration carries the same label, the stub is a broken + # duplicate of it — never the better answer, and never something the caller + # could disambiguate anyway, since it has no path to retry with. Drop the + # stubs so the exact tier holds only real declarations (#49, RC3 of #46). + sourced_exact = [nid for nid in exact if str(G.nodes[nid].get("source_file") or "")] + if sourced_exact: + exact = sourced_exact + if source_exact: query_basename = _strip_diacritics(Path(label).name).lower() preferred = [] @@ -1376,6 +1386,19 @@ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]: tier is split that way, else `[]`. Several matches *within one file* (a file node plus its members) are ordinary precedence, not ambiguity, and return `[]`. + Sourceless nodes are the exception to the per-file grouping, in *every* tier: + each one counts as its own rival. They all carry `source_file == ""`, so keying + them by source made N unrelated stub nodes look like N members of a single + file — the 18 stubs shadowing `BalanceitemRepository` reported no ambiguity at + all, and the caller answered with `matches[0]` (#49, RC3 of #46). + + Note the two halves of #49 have different reach. `_find_node_tiers` drops + stubs from a mixed tier only for the *exact* tier, so an exact tier arrives + here already reduced to real declarations and the per-stub keying changes + nothing for it. A winning prefix or substring tier is not reduced and can + still arrive mixed; there the keying is what stops its stubs from hiding + behind one another. + `_disambiguate_file_node_labels` (#2032) already relabels colliding *file* nodes; this covers the symbol case it does not reach. """ @@ -1385,7 +1408,8 @@ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]: by_source: dict[str, str] = {} for nid in tier: source = str(G.nodes[nid].get("source_file") or "") - by_source.setdefault(source, nid) + # "\0" can't occur in a path, so a stub never joins a real file's group. + by_source.setdefault(source or "\0" + nid, nid) return list(by_source.values()) if len(by_source) > 1 else [] return [] diff --git a/tests/test_stub_shadow_ambiguity.py b/tests/test_stub_shadow_ambiguity.py new file mode 100644 index 0000000000..e8dea9f839 --- /dev/null +++ b/tests/test_stub_shadow_ambiguity.py @@ -0,0 +1,243 @@ +"""Sourceless stubs must not shadow a real declaration, nor collapse into one +rival group (#49, RC3 of #46). + +`find_node_ambiguity` grouped the winning tier by `source_file`. Every sourceless +stub carries `source_file == ""`, so N stubs counted as a single file: no +ambiguity was reported and `explain` answered with `matches[0]` — the graph's +iteration order. The 18 exact-tier stubs shadowing `BalanceitemRepository` on the +pinned api corpus are the field case; the shapes below are the synthetic +equivalents (Q4 = sourced + N stubs, Q2 = sourced + 1 stub). +""" +from __future__ import annotations + +import json + +import pytest +from networkx.readwrite import json_graph + +import graphify.__main__ as mainmod +from graphify.affected import resolve_seed +from graphify.serve import _find_node, find_node_ambiguity + + +LABEL = "FooRepository" +SOURCED_ID = "app_repo_foorepository_foorepository" +SOURCE_FILE = "app/Repo/FooRepository.php" + + +def _stub(index: int, *, omit_source_key: bool = False) -> dict: + """A sourceless stub: `_php_emit_base`'s bare shadow, or a per-file salted one. + + `omit_source_key` reproduces the attributeless node serve materializes for a + dangling edge endpoint (no `source_file` key at all, not an empty one). + """ + node = {"id": f"app_uses_consumer{index}_php_foorepository", "label": LABEL, + "community": 0} + if not omit_source_key: + node["source_file"] = "" + return node + + +def _sourced() -> dict: + return {"id": SOURCED_ID, "label": LABEL, "source_file": SOURCE_FILE, + "source_location": "L22", "community": 0} + + +def _graph_dict(nodes: list[dict]) -> dict: + return {"directed": False, "multigraph": False, "graph": {}, + "nodes": nodes, "links": []} + + +def _load(nodes: list[dict]): + return json_graph.node_link_graph( + {**_graph_dict(nodes), "directed": True}, edges="links") + + +def _write(tmp_path, nodes: list[dict], name: str = "graph.json"): + p = tmp_path / name + p.write_text(json.dumps(_graph_dict(nodes))) + return p + + +def _run_explain(monkeypatch, graph_path, label, capsys): + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", + ["graphify", "explain", label, "--graph", str(graph_path)]) + code = None + try: + mainmod.main() + except SystemExit as exc: + code = exc.code + return capsys.readouterr().out, code + + +# --- Q4: one sourced declaration + N sourceless stubs ------------------------ + + +@pytest.mark.parametrize("stub_count", [2, 3, 18]) +def test_sourced_node_wins_over_n_sourceless_stubs(stub_count): + """Criterion 1/4: never a silent arbitrary stub, at any stub count. + + The guard must not depend on stub counts staying high — post-#47/#48 graphs + have fewer stubs, and the residual ones must still be handled. + """ + stubs = [_stub(i) for i in range(stub_count)] + # Stubs first: matches[0] was a stub under graph-iteration order. + G = _load(stubs + [_sourced()]) + assert _find_node(G, LABEL)[0] == SOURCED_ID + assert find_node_ambiguity(G, LABEL) == [] + + +def test_q4_answer_does_not_depend_on_node_order(): + stubs = [_stub(i) for i in range(3)] + forward = _load(stubs + [_sourced()]) + reverse = _load([_sourced()] + list(reversed(stubs))) + assert _find_node(forward, LABEL)[0] == _find_node(reverse, LABEL)[0] == SOURCED_ID + + +def test_q4_explain_reports_the_sourced_node(monkeypatch, tmp_path, capsys): + p = _write(tmp_path, [_stub(i) for i in range(18)] + [_sourced()]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert f" ID: {SOURCED_ID}" in out + assert SOURCE_FILE in out + assert "Ambiguous" not in out + assert code != 1 + + +def test_attributeless_dangling_endpoints_are_also_stubs(): + """A dangling edge endpoint has no `source_file` key at all — same rule.""" + stubs = [_stub(i, omit_source_key=True) for i in range(3)] + G = _load(stubs + [_sourced()]) + assert _find_node(G, LABEL)[0] == SOURCED_ID + assert find_node_ambiguity(G, LABEL) == [] + + +# --- all-sourceless tier: no real node to prefer, so refuse ------------------ + + +@pytest.mark.parametrize("stub_count", [2, 18]) +def test_sourceless_rivals_alone_are_ambiguous_not_an_arbitrary_pick(stub_count): + """The bug proper: N stubs shared the `""` bucket and looked unambiguous.""" + G = _load([_stub(i) for i in range(stub_count)]) + assert len(find_node_ambiguity(G, LABEL)) == stub_count + + +def test_explain_refuses_when_only_sourceless_stubs_match(monkeypatch, tmp_path, capsys): + p = _write(tmp_path, [_stub(i) for i in range(3)]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert "Ambiguous" in out + assert code == 1 + assert "Node: FooRepository\n ID:" not in out + + +# --- a stub with no sourced rival is still an answer ------------------------ + + +@pytest.mark.parametrize("omit_source_key", [False, True]) +def test_lone_stub_with_no_sourced_rival_still_resolves(omit_source_key): + """The rule demotes stubs against a real declaration — it does not delete them. + + `resolve_seed`'s exact-label pass no longer returns a lone *sourceless* match + outright (a decorated "name()" declaration is invisible to that pass but + visible to the bare-name pass below it, and serve's exact tier — matching both + forms — would prefer it). A stub with nothing to lose to must therefore still + come back from a later pass rather than fall through to None. + + This asserts that outcome, not the mechanism: for an undecorated label both + the bare-name pass and the `contains` pass below it return the stub, so either + alone would satisfy this. `test_bare_name_pass_is_what_recovers_a_lone_stub` + isolates the pass the fall-through actually leans on. + """ + stub = _stub(0, omit_source_key=omit_source_key) + G = _load([stub]) + assert _find_node(G, LABEL) == [stub["id"]] + assert find_node_ambiguity(G, LABEL) == [] + assert resolve_seed(G, LABEL) == stub["id"] + + +def test_bare_name_pass_is_what_recovers_a_lone_stub(): + """A decorated sourceless stub that the `contains` pass cannot rescue. + + `handle()` never enters the exact-label pass (its stored label keeps the + decoration, the query does not), and the `contains` pass ties it with the + `handleRequest()` sibling. Only the bare-name pass sees it alone — so this is + the assertion that goes red if that pass stops returning lone matches, which + is the fall-through the exact-label change depends on. + """ + G = _load([ + {"id": "stub_handle", "label": "handle()", "source_file": "", "community": 0}, + {"id": "app_svc_php_handlerequest", "label": "handleRequest()", + "source_file": "app/Svc.php", "source_location": "L14", "community": 0}, + ]) + assert resolve_seed(G, "handle") == "stub_handle" + + +def test_lone_stub_explains_rather_than_reporting_a_phantom_ambiguity( + monkeypatch, tmp_path, capsys +): + p = _write(tmp_path, [_stub(0)]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert f" ID: {_stub(0)['id']}" in out + assert "Ambiguous" not in out + assert code != 1 + + +# --- Q2: one sourced + one sourceless --------------------------------------- + + +def test_q2_shape_does_not_regress(): + """Criterion 2: resolve to the sourced node (never the stub, never silent).""" + G = _load([_stub(0), _sourced()]) + assert _find_node(G, LABEL)[0] == SOURCED_ID + assert find_node_ambiguity(G, LABEL) == [] + + +def test_q2_explain_reports_the_sourced_node(monkeypatch, tmp_path, capsys): + p = _write(tmp_path, [_stub(0), _sourced()]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert f" ID: {SOURCED_ID}" in out + assert code != 1 + + +# --- explain / affected consistency (criterion 3) --------------------------- + + +@pytest.mark.parametrize("stub_count", [1, 2, 18]) +def test_explain_and_affected_agree_when_a_sourced_node_exists(stub_count): + """`resolve_seed` refused with "No unique node match" while `explain` + answered — that divergence is #46's symptom.""" + G = _load([_stub(i) for i in range(stub_count)] + [_sourced()]) + assert resolve_seed(G, LABEL) == _find_node(G, LABEL)[0] == SOURCED_ID + + +@pytest.mark.parametrize("stub_count", [2, 18]) +def test_explain_and_affected_both_refuse_when_every_rival_is_sourceless(stub_count): + G = _load([_stub(i) for i in range(stub_count)]) + assert resolve_seed(G, LABEL) is None + assert find_node_ambiguity(G, LABEL) # explain refuses too + + +def test_affected_prefers_sourced_node_for_a_callable_label(): + """`resolve_seed`'s bare-name pass (decorated "name()" labels) needs the + same rule as its exact-label pass.""" + nodes = [ + {"id": "stub_handle", "label": "handle", "source_file": "", "community": 0}, + {"id": "app_svc_php_handle", "label": "handle()", + "source_file": "app/Svc.php", "source_location": "L9", "community": 0}, + ] + G = _load(nodes) + assert resolve_seed(G, "handle") == "app_svc_php_handle" + + +# --- the pre-existing monorepo tie must still be reported ------------------- + + +def test_two_sourced_rivals_are_still_ambiguous(): + """Sourced-vs-sourced (#2032's symbol case) is untouched by the stub rule.""" + G = _load([ + {"id": "chat_port", "label": "MetricsPort", + "source_file": "services/chat/ports/metrics.port.ts", "community": 0}, + {"id": "scrape_port", "label": "MetricsPort", + "source_file": "services/scraping/ports/metrics.port.ts", "community": 0}, + ]) + assert len(find_node_ambiguity(G, "MetricsPort")) == 2 From 9c4db411d4aa13d943dd646205059333ee8eda09 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Fri, 7 Aug 2026 10:20:16 -0300 Subject: [PATCH 33/41] fix(extract): PHP interface/trait/enum declarations mint canonical nodes (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_PHP_CONFIG.class_types` held only `class_declaration`, so no node was ever minted for a PHP interface, trait or enum — 291 declarations in the pinned api.lawnstarter.com corpus (#46). Every resolution pass that could canonicalize an edge then had nothing to land on: `implements`/`mixes_in` kept bare sourceless stubs that shadow the real name in `explain`, `Foo::CONST` fan-in fragmented across per-file stubs, and `imports`/parameter-type `references` parked on the *file* node (or, when the filename differs from the type name, on a sourceless FQN-labeled stub). Add the three declaration kinds to `class_types`, mirroring Java and Groovy. An enum's body is an `enum_declaration_list` rather than a `declaration_list`, so `body_fallback_child_types` learns it. The `_resolve_php_type_references` raw-scan, which read `class_declaration` bodies only, now scans every declaration kind and both body shapes: without it `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded no raw text and fell through to the same-namespace guess, resolving to the wrong `Repo`/`Describes`. The `_php_non_class_types` pre-scan keeps refusing interface/enum/trait-typed receivers — a behavior change this does not make — but its comment and docstring no longer claim the three kinds mint no node, which is now false. RC1 of #46. The residual dangling `imports` edge for a type used only via `::class` (repro's `enum Status`, `OnlyImport.php`) is RC2 and stays with #48 — this change supplies the node that fix needs to land on. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + graphify/extract.py | 17 +- graphify/extractors/engine.py | 29 ++- graphify/extractors/resolution.py | 13 +- tests/test_php_declaration_nodes.py | 309 ++++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 14 deletions(-) create mode 100644 tests/test_php_declaration_nodes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ad8d75b1..e073f25417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: **`caller` and `callers` now infer the `call` context filter**, adopting upstream's `_CONTEXT_HINTS` addition. This supersedes the "the hint table itself is untouched, so which queries get context-filtered is unchanged" note in 0.9.36 below: "callers of X" now takes the filtered path that "who calls X" already took. On a bare class node that is exactly the stranding case #37/C3 was built for — a class owns no `call` edges — so the filter reaches nothing beyond the seeds, the traversal relaxes to unfiltered, and the header says `Context: call (heuristic; relaxed — no matches beyond seeds)`. The answer is unchanged in content (all 3 callers still render on the 48.5k-node Laravel corpus); only the header note is new. Upstream shipped this hint without the relaxation fallback, so on upstream the same query strands; on the fork it does not. - Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip 0.9.36 shipped (`lawnstarter/graphify#37` C2; adopted from the `Graphify-Labs#2516` review, upstream `234c5f0`). 0.9.36 let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. - Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. +- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. +- Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. This supersedes the "Nothing in this release changes extraction, merge or the graph format on the fork's side" clause in the version-bump note below: a PHP corpus must be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. +- Two related defects are deliberately NOT fixed here and keep their own issues. An `imports` edge whose target never got a stub node — a type used only via `Foo::class`, which hits classes too — is still skipped by the repoint pass and still dangles, because that pass reads the target's label off a node that does not exist rather than off the edge's own `target_fqn` metadata (`lawnstarter/graphify#48`); this change supplies the declaration node that fix needs to land on, so the two compose. And the member-call resolver still REFUSES to bind a receiver typed by an interface, enum or trait (the refusal described in the fork's 0.9.35 section below): the collision it was built for — `App\Contracts\Notifier` beside `App\Support\Notifier` — now presents two definitions and would be refused by the single-definition guard on its own, so the pre-scan is now redundant conservatism rather than a workaround for a missing node, but lifting it is a behavior change on its own merits. That fan-in stays unrealized for now. - The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. ## 0.9.36 (unreleased) diff --git a/graphify/extract.py b/graphify/extract.py index c6d38ef4b0..db5d85137a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1062,7 +1062,17 @@ def _get_c_func_name(node, source: bytes) -> str | None: _PHP_CONFIG = LanguageConfig( ts_module="tree_sitter_php", ts_language_fn="language_php", - class_types=frozenset({"class_declaration"}), + # `interface`, `trait` and `enum` are declaration kinds exactly like `class` + # (all four carry a `name` field and a body of member declarations), so they + # mint definition nodes too — mirroring Java (above) and Groovy. Leaving them + # out meant no PHP interface/trait/enum ever had a canonical node, so every + # resolution pass had nothing to land on: implements/mixes_in kept bare + # sourceless stubs, `Foo::CONST` fan-in fragmented across per-file stubs, and + # imports/param-type references parked on the *file* node or a FQN stub (#47). + class_types=frozenset({ + "class_declaration", "interface_declaration", "trait_declaration", + "enum_declaration", + }), function_types=frozenset({"function_definition", "method_declaration"}), import_types=frozenset({"namespace_use_clause"}), # `$obj?->method()` parses as a distinct node type with the same @@ -1077,7 +1087,10 @@ def _get_c_func_name(node, source: bytes) -> str | None: call_accessor_node_types=frozenset({"member_call_expression"}), call_accessor_field="name", name_fallback_child_types=("name",), - body_fallback_child_types=("declaration_list", "compound_statement"), + # An enum's body is an `enum_declaration_list`, not a `declaration_list`; it + # is reachable through the `body` field, but the fallback has to know it too + # for the paths that scan children by type (#47). + body_fallback_child_types=("declaration_list", "enum_declaration_list", "compound_statement"), function_boundary_types=frozenset({"function_definition", "method_declaration"}), import_handler=_import_php, ) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 21cae54ae4..0b030c0739 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -144,8 +144,12 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: stack.extend(n.children) return out -# 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 declaration kinds whose names the member-call resolver refuses to bind a +# receiver to (#1682). All three ARE in `_PHP_CONFIG.class_types` and do mint +# definition nodes as of #47, so this is no longer "the kinds outside +# class_types" — it is the refusal policy, stated for its own sake. The name is +# historical and kept because the marker it feeds (`_php_non_class_types`) is +# persisted in graph.json and read back on incremental rebuilds (#11/#12). _PHP_NON_CLASS_DECLARATIONS = frozenset({ "interface_declaration", "enum_declaration", @@ -156,16 +160,21 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: 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 conventions make the collision that follows realistic: an - `App\\Contracts\\Notifier` interface beside an unrelated + The member-call resolver refuses to bind a receiver typed with one of these + names. Laravel's conventions make the collision that motivates the refusal + 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 + Eloquent `App\\Models\\Status` — used to leave exactly ONE definition under + that short name, which satisfied the single-definition guard and bound 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. + can refuse instead. + + Since #47 all three kinds mint definition nodes, so that same collision now + presents TWO definitions and the single-definition guard would refuse on its + own. The pre-scan is kept anyway: it still refuses the case the guard cannot + see — a lone interface with no same-named class, which would otherwise start + binding — and that is a behavior change #47 deliberately did not make. + Whether to lift the refusal now that the nodes exist is a separate decision. """ out: set[str] = set() stack = [root_node] diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index cfab262287..bfcadb0a08 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2489,6 +2489,15 @@ def _external_stub(fqn: str) -> str: _PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in") _PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"}) +# Every PHP declaration kind that mints a definition node (`_PHP_CONFIG.class_types`) +# and can therefore carry an extends/implements/`use`-trait clause. The raw-scan +# below used to read `class_declaration` only, so `interface Reader extends +# Sub\Repo` recorded no raw text and fell through to the same-namespace guess — +# which resolves to the wrong `Repo` when both exist (#47). +_PHP_DECLARATION_TYPES = frozenset({ + "class_declaration", "interface_declaration", "trait_declaration", "enum_declaration", +}) + def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: """Resolve a raw (possibly qualified) PHP class reference to an FQN. @@ -2685,7 +2694,7 @@ def walk(n) -> None: if key: uses.setdefault(key, fqn) return - elif t == "class_declaration": + elif t in _PHP_DECLARATION_TYPES: for child in n.children: if child.type == "base_clause": for sub in child.children: @@ -2695,7 +2704,7 @@ def walk(n) -> None: for sub in child.children: if sub.type in ("name", "qualified_name"): _record_raw("implements", _read_text(sub, source)) - elif child.type == "declaration_list": + elif child.type in ("declaration_list", "enum_declaration_list"): for member in child.children: if member.type != "use_declaration": continue diff --git a/tests/test_php_declaration_nodes.py b/tests/test_php_declaration_nodes.py new file mode 100644 index 0000000000..4f8878242b --- /dev/null +++ b/tests/test_php_declaration_nodes.py @@ -0,0 +1,309 @@ +"""PHP `interface` / `trait` / `enum` declarations mint canonical nodes (#47, RC1 of #46). + +`_PHP_CONFIG.class_types` used to hold only `class_declaration`, so no node was +ever minted for a PHP interface, trait or enum. Every resolution pass that could +canonicalize an edge then had nothing to land on: the implements edge kept a bare +sourceless stub, `Foo::CONST` fan-in fragmented across per-file stubs, and +`imports`/parameter-type `references` parked on the file node (or, when the file +name differs from the type name, on a sourceless FQN-labeled stub). + +The control experiment in #46 is the shape these tests pin: change +`interface FooRepository` to `class FooRepository` and every one of those edges +canonicalizes onto the single declaration node. Interfaces, traits and enums must +behave the same way. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _defs(result: dict, label: str) -> list[dict]: + """Sourced (declaration) nodes carrying `label`.""" + return [n for n in result["nodes"] if n.get("label") == label and n.get("source_file")] + + +def _one_def(result: dict, label: str) -> dict: + defs = _defs(result, label) + assert len(defs) == 1, f"expected exactly one sourced `{label}` node, got {len(defs)}" + return defs[0] + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _edges(result: dict, relation: str) -> list[dict]: + return [e for e in result["edges"] if e.get("relation") == relation] + + +def _repro_corpus(tmp_path: Path) -> list[Path]: + """The 7-file minimal repro from #46, verbatim in shape.""" + return [ + _write( + tmp_path / "app/Repo/FooRepository.php", + "repo = $repo;\n }\n\n" + " public function tag(): string\n {\n return FooRepository::BAR;\n }\n}\n", + ), + _write( + tmp_path / "app/Uses/Consumer2.php", + "repo = $repo;\n }\n\n" + " public function tag(): string\n {\n return FooRepository::BAR;\n }\n}\n", + ), + _write( + tmp_path / "app/Repo/Extras.php", + "value;\n }\n}\n", + ), + _write( + tmp_path / "app/Uses/OnlyImport.php", + " {e['target']}" + ) + + # Cascade A / B: no sourceless FooRepository stub survives anywhere. + stubs = [ + n for n in result["nodes"] + if not n.get("source_file") and "foorepository" in n.get("id", "").lower() + ] + assert stubs == [], f"sourceless FooRepository stubs survived: {[n['id'] for n in stubs]}" + + # The loops above filter candidates by looking their target's LABEL up in the + # node set, so an edge left pointing at a bare `foorepository` with no node at + # all resolves to no label, drops out of `landed`, and is never checked. Close + # that blind spot separately: a FooRepository edge target that names no node is + # a regression whatever else canonicalized correctly. + # + # Matched on the id's last segment, not as a substring: the bare Cascade A id + # (`foorepository`) and the Cascade B per-file salts (`_php_foorepository`) + # both end in it, while `dbfoorepository` — a different type, and the repro's + # one legitimately dangling target until RC2 (#48) lands — does not. + node_ids = {n["id"] for n in result["nodes"]} + dangling = sorted( + { + e["target"] for e in result["edges"] + if str(e.get("target", "")).lower().rsplit("_", 1)[-1] == "foorepository" + and e["target"] not in node_ids + } + ) + assert dangling == [], f"FooRepository edge targets with no node: {dangling}" + + +def test_php_trait_and_enum_declared_in_a_differently_named_file(tmp_path: Path): + # #47 criterion 2: `trait Loggable` and `enum Status` live in `Extras.php` + # (filename != type name), so the id-collision accident that lets a class's + # edges land on its file node cannot save them. Both need real nodes. + result = extract(_repro_corpus(tmp_path), cache_root=tmp_path) + + loggable = _one_def(result, "Loggable") + status = _one_def(result, "Status") + assert loggable["source_file"].endswith("app/Repo/Extras.php") + assert status["source_file"].endswith("app/Repo/Extras.php") + + mixes_in = _edges(result, "mixes_in") + assert mixes_in, "expected a mixes_in edge from Consumer3" + for e in mixes_in: + assert e["target"] == loggable["id"] + + imports_by_target = {e["target"] for e in _edges(result, "imports")} + assert loggable["id"] in imports_by_target, "trait import did not land on the trait node" + + # The trait's edges used to park on a sourceless `App\Repo\Loggable` FQN stub, + # because `Extras.php` cannot absorb them by the id-collision accident that + # saves a PSR-4-named class. No such stub may survive. + assert not [ + n for n in result["nodes"] + if not n.get("source_file") and n.get("label") in ("App\\Repo\\Loggable", "Loggable", "Status") + ] + + # `enum Status` is imported by Consumer3 but only used as `Status::Active`, so + # nothing mints a stub node for it and the repoint pass — which reads the + # target's stub LABEL — skips the edge. That is RC2 (#48), an independent root + # cause: the edge must be resolved from its own `target_fqn` metadata. RC1's + # contract here is the half #48 needs and cannot supply: the node exists, is + # sourced, and carries the FQN the metadata will resolve to. + enum_import = next( + e for e in _edges(result, "imports") + if (e.get("metadata") or {}).get("target_fqn") == "App\\Repo\\Status" + ) + assert enum_import["target"] not in {n["id"] for n in result["nodes"]}, ( + "enum import no longer dangles — RC2 (#48) has landed; tighten this " + "assertion to `enum_import['target'] == status['id']`" + ) + + +def test_php_enum_body_members_and_clauses(tmp_path: Path): + # An enum's body is an `enum_declaration_list`, not the `declaration_list` + # every other PHP declaration uses. Its methods, its `implements` clause and + # its trait `use` must all be picked up regardless (#47 enum caveat). + contract = _write( + tmp_path / "app/Contracts/HasLabel.php", + "value; }\n}\n", + ) + result = extract([contract, trait, enum], cache_root=tmp_path) + + status = _one_def(result, "Status") + has_label = _one_def(result, "HasLabel") + describes = _one_def(result, "Describes") + + methods = [e for e in _edges(result, "method") if e["source"] == status["id"]] + assert [ + (_node_by_id(result, e["target"]) or {}).get("label") for e in methods + ] == [".label()"], "enum method did not attach to the enum node" + + assert [e["target"] for e in _edges(result, "implements")] == [has_label["id"]] + assert [e["target"] for e in _edges(result, "mixes_in")] == [describes["id"]] + + +def test_php_interface_extends_qualified_interface_resolves(tmp_path: Path): + # #47 criterion 4: the `_resolve_php_type_references` raw-scan only recognised + # `class_declaration`, so an interface's `extends` clause was never recorded. + # `interface Reader extends Sub\Repo` would then fall through to the + # same-namespace guess and bind to the WRONG `Repo`. + outer = _write( + tmp_path / "app/Contracts/Repo.php", + " Date: Fri, 7 Aug 2026 10:17:36 -0300 Subject: [PATCH 34/41] fix(resolution): resolve imports edges from their own target_fqn metadata (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_import_php` keys the `imports` edge on the imported short name and mints no node for it, so a `use` whose name is only ever written as `Foo::class` leaves no stub node behind. `_resolve_php_type_references` read the name to resolve off a stub node's label, so it skipped those edges outright and the bare target survived into graph.json with no node behind it — serve then materialized an attributeless degree-1 node at load (RC2 of #46). Resolve `imports` from the edge's own `target_fqn` metadata instead, stamped by `_import_php` since #19. The FQN is explicit proof of what the `use` statement said, so an in-corpus target lands on its real node and an external one parks on an FQN-labeled stub the bare-name rewire cannot collapse — never a bare match onto an unrelated same-short-name type. Aliased imports resolve for the same reason. `use function` / `use const` name callables, not types, and are left alone. The vacated target only feeds the orphan prune when it really was a stub: the old control flow guaranteed that for free by reaching the prune set only after a `stub_label` hit, but a metadata-resolved import target is a bare `_make_id()` that can collide with an unrelated real node (a `Page.md` doc node vs `use Vendor\Pkg\Page;`), and pruning that would delete a sourced node the import never touched. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/resolution.py | 70 +++++++---- tests/test_php_declaration_nodes.py | 15 ++- tests/test_php_import_target_fqn.py | 177 ++++++++++++++++++++++++++++ tests/test_php_use_imports.py | 37 ++++-- 5 files changed, 264 insertions(+), 36 deletions(-) create mode 100644 tests/test_php_import_target_fqn.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e073f25417..353d7b104b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. - Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. This supersedes the "Nothing in this release changes extraction, merge or the graph format on the fork's side" clause in the version-bump note below: a PHP corpus must be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. - Two related defects are deliberately NOT fixed here and keep their own issues. An `imports` edge whose target never got a stub node — a type used only via `Foo::class`, which hits classes too — is still skipped by the repoint pass and still dangles, because that pass reads the target's label off a node that does not exist rather than off the edge's own `target_fqn` metadata (`lawnstarter/graphify#48`); this change supplies the declaration node that fix needs to land on, so the two compose. And the member-call resolver still REFUSES to bind a receiver typed by an interface, enum or trait (the refusal described in the fork's 0.9.35 section below): the collision it was built for — `App\Contracts\Notifier` beside `App\Support\Notifier` — now presents two definitions and would be refused by the single-definition guard on its own, so the pre-scan is now redundant conservatism rather than a workaround for a missing node, but lifting it is a behavior change on its own merits. That fan-in stays unrealized for now. +- Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. - The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. ## 0.9.36 (unreleased) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index bfcadb0a08..d5b5cd3fd6 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2637,7 +2637,12 @@ def _resolve_php_type_references( proven external by a ``use`` FQN or a qualified name are re-pointed to an FQN-labeled sourceless stub, which the bare-label rewire cannot collapse. References with no namespace facts are left untouched so the unique-label - rewire keeps handling plain (non-namespaced) PHP as before. + rewire keeps handling plain (non-namespaced) PHP as before — except for + class ``imports``, which carry their own ``target_fqn`` (#19) and so are + re-pointed off that even in a file with no ``namespace`` declaration (#48): + the ``use`` FQN is itself the namespace fact, and the edge's bare target was + never a rewire candidate anyway (``_rewire_unique_stub_nodes`` builds its + candidate list from nodes, and a ``Foo::class``-only import mints none). """ try: import tree_sitter_php as tsphp @@ -2777,39 +2782,62 @@ def _external_stub(fqn: str) -> str: if ref_file not in ns_by_file: continue tgt = edge.get("target") - label = stub_label.get(tgt) - if not label: - continue - bare = label.strip().lower() ns = ns_by_file[ref_file] uses = uses_by_file.get(ref_file, {}) - raw = None - if relation in _PHP_SUPERTYPE_RELATIONS: - raw = raw_by_file.get(ref_file, {}).get((relation, bare)) - - explicit = False - if raw and "\\" in raw: - fqn = _php_fqn_from_raw(raw, ns, uses) - explicit = True - elif bare in uses: - fqn = uses[bare] - explicit = True - elif ns: - fqn = f"{ns}\\{label}" + # An `imports` edge already spells its own FQN in metadata (stamped by + # `_import_php` since #19), so it resolves without a stub node to read a + # label from — the `Foo::class`-only import mints no stub at all, and + # the bare target used to dangle all the way into graph.json (#48). + # Only `use`d *types* belong in the type map: `use function`/`use const` + # name callables, not classes. + import_fqn = "" + if relation == "imports": + meta = edge.get("metadata") or {} + if meta.get("use_kind", "class") == "class": + import_fqn = str(meta.get("target_fqn") or "").strip().lstrip("\\") + + if import_fqn: + fqn, explicit = import_fqn, True else: - continue # no namespace facts: legacy unique-label rewire applies + label = stub_label.get(tgt) + if not label: + continue + bare = label.strip().lower() + + raw = None + if relation in _PHP_SUPERTYPE_RELATIONS: + raw = raw_by_file.get(ref_file, {}).get((relation, bare)) + + explicit = False + if raw and "\\" in raw: + fqn = _php_fqn_from_raw(raw, ns, uses) + explicit = True + elif bare in uses: + fqn = uses[bare] + explicit = True + elif ns: + fqn = f"{ns}\\{label}" + else: + continue # no namespace facts: legacy unique-label rewire applies resolved = fqn_to_id.get(fqn.lower()) if resolved and resolved != tgt: edge["target"] = resolved - repointed_from.add(tgt) elif explicit and resolved is None: # Proven external: park the edge on an FQN-labeled stub the # bare-name rewire cannot collapse (this is the #1923 fix). edge["target"] = _external_stub(fqn) + else: + continue # non-explicit miss: leave the bare stub for the legacy rewire + + # Only a *stub* id is a candidate for the orphan prune below. Resolving + # an import off its own metadata means `tgt` may be a bare + # `_make_id()` that never had a node of ours behind it — and + # that id can collide with an unrelated real one (a `Page.md` doc node + # vs `use Vendor\Pkg\Page;`). Vacating this edge must never orphan it. + if tgt in stub_label: repointed_from.add(tgt) - # non-explicit miss: leave the bare stub for the legacy rewire if new_nodes: all_nodes.extend(new_nodes) diff --git a/tests/test_php_declaration_nodes.py b/tests/test_php_declaration_nodes.py index 4f8878242b..c4f72c5c29 100644 --- a/tests/test_php_declaration_nodes.py +++ b/tests/test_php_declaration_nodes.py @@ -188,18 +188,17 @@ def test_php_trait_and_enum_declared_in_a_differently_named_file(tmp_path: Path) ] # `enum Status` is imported by Consumer3 but only used as `Status::Active`, so - # nothing mints a stub node for it and the repoint pass — which reads the - # target's stub LABEL — skips the edge. That is RC2 (#48), an independent root - # cause: the edge must be resolved from its own `target_fqn` metadata. RC1's - # contract here is the half #48 needs and cannot supply: the node exists, is - # sourced, and carries the FQN the metadata will resolve to. + # nothing mints a stub node for it. RC1 (#47) supplies the sourced node with + # the FQN, and RC2 (#48) resolves the edge from its own `target_fqn` metadata + # instead of a stub label — with both landed, the import canonicalizes onto + # the enum's declaration node. enum_import = next( e for e in _edges(result, "imports") if (e.get("metadata") or {}).get("target_fqn") == "App\\Repo\\Status" ) - assert enum_import["target"] not in {n["id"] for n in result["nodes"]}, ( - "enum import no longer dangles — RC2 (#48) has landed; tighten this " - "assertion to `enum_import['target'] == status['id']`" + assert enum_import["target"] == status["id"], ( + "the enum's imports edge must land on the sourced declaration node " + "now that RC1 (#47) and RC2 (#48) are both present" ) diff --git a/tests/test_php_import_target_fqn.py b/tests/test_php_import_target_fqn.py new file mode 100644 index 0000000000..a667fcf1de --- /dev/null +++ b/tests/test_php_import_target_fqn.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _imports_from(result: dict, needle: str) -> list[dict]: + return [ + e for e in result["edges"] + if e["relation"] == "imports" and needle in e.get("source_file", "") + ] + + +def test_php_import_only_used_as_class_const_lands_on_the_real_class(tmp_path: Path): + # #48 (RC2 of #46): `use App\Repo\DbFooRepository;` used only via `::class` + # mints no stub node for the imported name, so the repoint pass used to skip + # the edge (no `stub_label` entry) and the bare target dangled — even though + # the real class node exists in the corpus. The edge's own `target_fqn` + # metadata is enough to resolve it. + repo = _write( + tmp_path / "app/Repo/DbFooRepository.php", + "\`. The edge's + # own `target_fqn` carries the truth. + repo = _write( + tmp_path / "app/Repo/DbFooRepository.php", + ")` that no node of ours ever owned — and that id can + # collide with a real, sourced, edgeless node (`Page.md` -> id `page`). The + # orphan prune must not delete it just because an unrelated `use` line + # mentioned the same short name. + doc = _write(tmp_path / "Page.md", "Some prose about pages.\n") + consumer = _write( + tmp_path / "app/U.php", + " Path: @@ -137,16 +139,37 @@ def test_php_trait_use_inside_a_class_body_is_not_an_import(tmp_path: Path): def test_php_import_edge_targets_are_unchanged_by_metadata_capture(tmp_path: Path): - # Regression guard: capturing the FQN must not re-point the edge. Targets - # stay keyed on the imported short name exactly as before. + # Regression guard: capturing the FQN must not re-point the edge. At capture + # time the targets stay keyed on the imported short name exactly as before — + # re-pointing is the resolver's job (see the companion test below). f = _write( tmp_path / "lib.php", " Date: Fri, 7 Aug 2026 10:24:30 -0300 Subject: [PATCH 35/41] fix(extract): bind PHP receiver type from ctor-body assignment of a typed param (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP receiver typing read the class's typed properties and constructor-promoted params only, so the pre-promotion Laravel idiom — property declared untyped with its type in a `@var` docblock, collaborator arriving as a typed `__construct` param the body assigns across — bound no receiver type and minted no `calls` edge. Measured case: `BalanceCustomerAccountService::handle()` had no edge to `ChargeCustomerService::handle()` on the 48.5k-node Laravel corpus, while its two sibling callers (promoted param, typed method param) resolved fine. `_php_ctor_assigned_field_types` types the properties a constructor body assigns from a typed constructor parameter and merges them UNDER the declared and promoted types, so a real annotation — including a union's multi-class refusal (#9) — always wins. The parameter's signature is the evidence; `@var` docblocks stay out of scope and are not parsed. Refusal discipline mirrors the local-variable one (#4): the property is poisoned rather than guessed at unless its binding is provably single-typed — a second assignment of a different or untypable type, an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body (including by a shadowing closure parameter, which the reused local-table walk already poisons). A property is not a local, so that refusal is PROPERTY-scoped rather than constructor-scoped: every other method of the class is walked for writes to `$this->prop` too, and any it finds refuses the binding. Such a write is the same category as one made from inside a closure — deferred, possibly running never, later, or repeatedly — so both take one `deferred` path that can only refuse, never bind. Without it a setter reassigning the property to another class left the constructor's binding standing and minted a wrong INFERRED edge. The declared path needs no such sweep: PHP enforces a native property type on every write, so a setter cannot retype a typed or promoted property. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/engine.py | 205 +++++ tests/test_php_ctor_body_receiver.py | 1074 ++++++++++++++++++++++++++ 3 files changed, 1280 insertions(+) create mode 100644 tests/test_php_ctor_body_receiver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 353d7b104b..1ef835fd52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. This supersedes the "Nothing in this release changes extraction, merge or the graph format on the fork's side" clause in the version-bump note below: a PHP corpus must be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. - Two related defects are deliberately NOT fixed here and keep their own issues. An `imports` edge whose target never got a stub node — a type used only via `Foo::class`, which hits classes too — is still skipped by the repoint pass and still dangles, because that pass reads the target's label off a node that does not exist rather than off the edge's own `target_fqn` metadata (`lawnstarter/graphify#48`); this change supplies the declaration node that fix needs to land on, so the two compose. And the member-call resolver still REFUSES to bind a receiver typed by an interface, enum or trait (the refusal described in the fork's 0.9.35 section below): the collision it was built for — `App\Contracts\Notifier` beside `App\Support\Notifier` — now presents two definitions and would be refused by the single-definition guard on its own, so the pre-scan is now redundant conservatism rather than a workaround for a missing node, but lifting it is a behavior change on its own merits. That fan-in stays unrealized for now. - Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. +- Fix: a PHP property assigned in the **constructor body** from a typed constructor parameter now types the `$this->prop->method()` receiver, so the call resolves to that parameter's class (`lawnstarter/graphify#38`). PHP receiver typing read the class's typed properties and constructor-promoted params only, which misses the pre-promotion idiom that dominates a Laravel corpus: the property is declared untyped with its type in a `@var` docblock, and the collaborator arrives as a typed `__construct` parameter that the body assigns across (`$this->chargeCustomerService = $chargeCustomerService;`). Measured on the 48.5k-node Laravel corpus, `BalanceCustomerAccountService::handle()` minted no `calls` edge to `ChargeCustomerService::handle()` while its two sibling callers — one using a promoted param, one a typed method param — resolved fine. The **parameter's signature is the evidence and `@var` docblocks are still not read**: a docblock-only property with no constructor assignment types nothing, and where docblock and signature disagree the signature wins. The refusal discipline is the local-variable one: the property is poisoned rather than guessed at when its binding is not provably single-typed — a second assignment of a different or untypable type (a container resolve, an inline `new`, a local, a literal), an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body, including by a shadowing closure parameter. Because a property is not a local, that refusal is judged over the whole **class**, not just the constructor: a write to the property from any other method — a setter, a `boot()`, a closure captured anywhere — refuses the constructor's binding, since it is a deferred write that may run never, later, or repeatedly, and an *untyped* property (unlike a natively typed one, which PHP fatals on an incompatible write) carries no runtime guarantee that would make it safe. The refusal is unconditional on the write and not conditional on the written type, so even a same-typed setter costs the edge — deliberate, and the trade this discipline makes everywhere else too. A real annotation always outranks the assignment, so a natively typed or promoted property keeps its declared type and a union-typed one keeps its multi-class refusal (#9); every downstream refusal — interface/enum/trait receivers (#5, #12), the `use`-claimed out-of-corpus name (#16), the single-definition guard — applies unchanged, since this only fills in the receiver type the resolver was already asking for. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to pick the new edges up. - The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. ## 0.9.36 (unreleased) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 0b030c0739..1d4396af16 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -975,6 +975,170 @@ def new_type_name(node) -> _PhpReceiverType | None: return table +def _php_ctor_assigned_field_types( + ctor_node, + other_method_nodes, + source: bytes, +) -> dict[str, _PhpReceiverType]: + """Type the properties a CONSTRUCTOR BODY assigns from a typed param (#38). + + The pre-promotion idiom that dominates a Laravel corpus declares the + property untyped — its type living only in a ``@var`` docblock — and takes + the collaborator as a typed constructor parameter:: + + /** @var ChargeCustomerService */ + protected $chargeCustomerService; + public function __construct(ChargeCustomerService $chargeCustomerService) { + $this->chargeCustomerService = $chargeCustomerService; + } + + The parameter's SIGNATURE is the evidence; the docblock is deliberately not + read (docblocks lie more often than signatures do). The result feeds the + same ``this.`` receiver table as a native property type, merged UNDER + it — a real annotation, including a union's refusal (#9), always wins. + + Refusal discipline mirrors the local-variable one: a property is POISONED — + left out entirely — unless its binding is provably single-typed. Poisoned by + a second assignment of a different or untypable type, an augmented or + by-reference assignment, or a list-destructuring or ``foreach`` target. + Every poison rule is order-independent, so one unordered walk decides them + all. + + A property is not a local, so the poison scope is the whole CLASS, not the + constructor: ``other_method_nodes`` (every other method of the same class) + is walked for writes to ``$this->prop`` too, and any it finds refuses the + binding. Such a write is the same category as a write from inside a closure + — deferred, and possibly running never, later, or repeatedly — so both take + the identical ``deferred`` path below, which can only refuse and never bind. + The refusal is unconditional on the write rather than conditional on the + written type: a same-typed setter is provably harmless and loses its edge + anyway, which is the trade this discipline makes everywhere else too. + The DECLARED path is untouched by all of this and needs no such sweep — PHP + enforces a native property type on every write, so a setter cannot retype a + typed or promoted property, while an untyped one carries no such guarantee. + + The right-hand side must name a parameter of THIS constructor whose own + binding survived ``_php_method_receiver_types``, which is what rules out an + untyped or union-typed parameter and one rebound anywhere in the body + (including by a shadowing closure parameter). A local built in the body is + not a parameter: ``$this->prop = $svc`` after ``$svc = new T()`` poisons the + property rather than binding it, keeping this to the one form #38 scopes. + """ + # Bare keys only (no field types are passed in): the constructor's own + # parameters and locals, already stripped of every poisoned name. + local_types = _php_method_receiver_types(ctor_node, source, {}) + + param_names: set[str] = set() + params = ctor_node.child_by_field_name("parameters") + for param in params.children if params is not None else (): + if param.type not in ("simple_parameter", "property_promotion_parameter"): + continue + name_node = param.child_by_field_name("name") + if name_node is not None: + param_names.add(_read_text(name_node, source).lstrip("$")) + + assigned: dict[str, _PhpReceiverType] = {} + refused: set[str] = set() + + def refuse(prop: str) -> None: + if prop: + assigned.pop(prop, None) + refused.add(prop) + + def bind(prop: str, declared: _PhpReceiverType | None) -> None: + if not prop or prop in refused: + return + previous = assigned.get(prop) + if declared is None or (previous is not None and previous.short != declared.short): + refuse(prop) + elif previous is not None and previous.qualified != declared.qualified: + # Same short name reached through two differently written params: + # keep the short binding, drop the conflicting qualified evidence + # (the local-table rule, #20). + assigned[prop] = _PhpReceiverType(previous.short, None) + else: + assigned[prop] = declared + + def this_prop(node) -> str: + """Property name in ``$this->prop``, or "" for any other target. + + Rejects ``$other->prop`` (a different object), ``$this->{$name}`` (the + name is computed) and ``$this->a->b`` (whose target is a property of + the property, leaving ``a``'s own binding untouched). + """ + if node is None or node.type != "member_access_expression": + return "" + obj = node.child_by_field_name("object") + name = node.child_by_field_name("name") + if obj is None or name is None or name.type != "name": + return "" + if obj.type != "variable_name" or _read_text(obj, source) != "$this": + return "" + return _read_text(name, source) + + def refuse_props_in(node) -> None: + """Refuse every ``$this->prop`` named anywhere in a binding-site subtree + (``[$this->a, $b] = …``, ``foreach … as $this->a``).""" + stack = [node] + while stack: + n = stack.pop() + if n is None: + continue + refuse(this_prop(n)) + stack.extend(n.children) + + def param_type(node) -> _PhpReceiverType | None: + """The type of the parameter ``node`` names, or None (which refuses).""" + if node is None or node.type != "variable_name": + return None + name = _read_text(node, source).lstrip("$") + return local_types.get(name) if name in param_names else None + + def body_children(method_node): + body = method_node.child_by_field_name("body") + return body.children if body is not None else () + + # `deferred` rides along with each node and marks a write whose timing is + # not the constructor's own: one made from inside a closure, or one made by + # another method of the class. The walk descends into both (to poison what + # they write) without ever letting them BIND. An abstract or interface + # method has no body and contributes nothing. + stack = [(child, False) for child in body_children(ctor_node)] + for method_node in other_method_nodes: + stack.extend((child, True) for child in body_children(method_node)) + while stack: + node, deferred = stack.pop() + if node.type in _PHP_FOREIGN_SCOPE_TYPES: + continue # a nested class/function has a different (or no) `$this` + if node.type == "assignment_expression": + left = node.child_by_field_name("left") + if left is not None and left.type == "list_literal": + refuse_props_in(left) + elif deferred: + refuse(this_prop(left)) + else: + prop = this_prop(left) + if prop: + bind(prop, param_type(node.child_by_field_name("right"))) + elif node.type in ("augmented_assignment_expression", + "reference_assignment_expression"): + # `$this->prop .= …` composes an unknown value; `$this->prop = &$svc` + # aliases the variable's storage, so rebinding it retypes the + # property. Neither is a single-typed binding. + refuse_props_in(node.child_by_field_name("left")) + elif node.type == "foreach_statement": + 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:]: # children are [iterated, target(s)…, body] + refuse_props_in(target) + nested = deferred or node.type in _PHP_CLOSURE_TYPES + stack.extend((child, nested) for child in node.children) + + for prop in refused: + assigned.pop(prop, None) + return assigned + + def _php_method_return_type_node(method_node): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False @@ -4481,6 +4645,47 @@ 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() } + # #38: a property assigned in the CONSTRUCTOR BODY from a typed constructor + # parameter (`$this->svc = $svc;`) is typed as surely as a natively typed + # property is — the pre-promotion idiom that dominates a Laravel corpus, + # where the declaration carries only a `@var` docblock. Merged with + # setdefault, so the declared and promoted types the walk already collected + # win, and this fills only the gaps. Runs before the tables below because + # `walk(root)` is complete, so declaration order is free and a call inside + # the constructor itself sees the binding too. + # + # Grouped by class because the refusal is PROPERTY-scoped, not + # constructor-scoped: every OTHER method of the same class is handed to the + # walk as well, so a setter that rewrites the property refuses the + # constructor's binding instead of leaving a stale one behind. `class_nid` + # keys the group, so a second class in the same file can never poison this + # one's properties — and an anonymous class registers no method scope at + # all (it is not a `class_declaration`, so it owns no class_nid; probed), + # which keeps its own `$this` writes out of every group. + php_class_methods: dict[str, list] = {} + for method_node, class_nid in php_method_scopes.values(): + php_class_methods.setdefault(class_nid, []).append(method_node) + + def _php_is_ctor(method_node) -> bool: + # PHP method names are case-insensitive, `__CONSTRUCT` included. + name_node = method_node.child_by_field_name("name") + return (name_node is not None + and _read_text(name_node, source).casefold() == "__construct") + + for class_nid, method_nodes in php_class_methods.items(): + ctor_node = next((m for m in method_nodes if _php_is_ctor(m)), None) + if ctor_node is None: + continue + assigned = _php_ctor_assigned_field_types( + ctor_node, + [m for m in method_nodes if m is not ctor_node], + source, + ) + if assigned: + fields = php_field_types.setdefault(class_nid, {}) + for prop, declared in assigned.items(): + fields.setdefault(prop, declared) + php_receiver_types = { body_id: _php_method_receiver_types( method_node, diff --git a/tests/test_php_ctor_body_receiver.py b/tests/test_php_ctor_body_receiver.py new file mode 100644 index 0000000000..d2802c234e --- /dev/null +++ b/tests/test_php_ctor_body_receiver.py @@ -0,0 +1,1074 @@ +"""Receiver typing from a constructor-BODY assignment of a typed param (#38). + +The pre-promotion Laravel idiom declares the property untyped (its type living +only in a `@var` docblock) and assigns it in the constructor body from a typed +constructor parameter:: + + /** @var ChargeCustomerService */ + protected $chargeCustomerService; + public function __construct(ChargeCustomerService $chargeCustomerService) { + $this->chargeCustomerService = $chargeCustomerService; + } + +The measured case is `app/Services/Billing/BalanceCustomerAccountService.php:14-20` +of api.lawnstarter.com @ `d2d4bed7ae`, whose `handle()` minted no `calls` edge to +`ChargeCustomerService::handle()`. The parameter's signature is the evidence — +the docblock is NOT read, and a property whose only type is a docblock stays +unbound (locked by `test_docblock_alone_still_emits_no_edge`). + +The refusal discipline mirrors the local-variable one (#4): a property whose +constructor-body binding is not provably single-typed is POISONED — left out of +the receiver table entirely — rather than bound to a guess. + +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 bound type can. +_SERVICE = " str: + """A controller class whose `index()` calls `$this->leadHunter->search([])`.""" + return ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ) + + +def _no_search_edge(calls: dict, caller: str) -> bool: + return not any(src == caller and "search" in tgt.lower() for src, tgt in calls) + + +# ── The measured case (#38) ─────────────────────────────────────────────────── + +def test_docblock_typed_property_assigned_in_ctor_body_resolves(tmp_path: Path): + """The exact shape of BalanceCustomerAccountService.php:14-20: untyped + property + `@var` docblock + ctor-body assignment from a typed param.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " /** @var LeadHunterService */\n" + " protected $leadHunter;\n" + "\n" + " public function __construct(\n" + " LeadHunterService $leadHunter\n" + " ) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + assert edge["context"] == "call" + + +def test_property_with_no_docblock_at_all_resolves(tmp_path: Path): + """The docblock is decoration, not evidence: the parameter's type is what + binds, so the same code without any docblock resolves identically.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $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_property_never_declared_at_all_resolves(tmp_path: Path): + """A dynamic property (assigned but never declared) binds the same way — + the assignment is the only declaration PHP needs.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $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_property_and_param_names_may_differ(tmp_path: Path): + """The property name comes from the assignment's LEFT side, the type from + its right — the two names need not agree.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\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_same_namespace_param_type_resolves_without_a_use_import(tmp_path: Path): + """The measured case names its collaborator with no `use` line: it lives in + the caller's own namespace. The namespace-aware binding (#22) applies to a + ctor-body-bound receiver exactly as it does to a declared one.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": _SERVICE, + "app/Audit/AuditLog.php": _DECOY, + "app/Services/LeadFinder.php": ( + "leadHunter = $leadHunter;\n" + " }\n" + " public function find(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + find = _find(r, ".find()", "leadfinder") + assert (find, _find(r, ".search()", "leadhunterservice")) in calls + assert (find, _find(r, ".search()", "auditlog")) not in calls + + +def test_qualified_param_type_resolves(tmp_path: Path): + """A param type written out fully qualified binds the same way.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(\\App\\Services\\LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_nullable_param_type_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(?LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $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_promoted_param_may_also_type_a_second_property(tmp_path: Path): + """A promoted param is a typed variable in the constructor's scope too, so + aliasing it onto another property binds that property as well.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(private LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\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_the_constructor_body_itself_can_use_the_binding(tmp_path: Path): + """The table is built before resolution, so a call in the constructor body + resolves against a property the same body binds.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $leadHunter;\n" + " $this->leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + ctor = _find(r, ".__construct()", "leadcontroller") + assert (ctor, _find(r, ".search()", "leadhunterservice")) in calls + assert (ctor, _find(r, ".search()", "auditlog")) not in calls + + +def test_two_classes_in_one_file_bind_their_own_constructors(tmp_path: Path): + """The binding is class-scoped: the same property name assigned from a + different type in each class resolves to its own class's parameter.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $svc;\n" + " }\n" + " public function index(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + "class AuditController {\n" + " protected $leadHunter;\n" + " public function __construct(AuditLog $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function index(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + lead_index = _find(r, ".index()", "leadcontroller") + audit_index = _find(r, ".index()", "auditcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "audit_auditlog") + assert (lead_index, service_search) in calls + assert (lead_index, decoy_search) not in calls + assert (audit_index, decoy_search) in calls + assert (audit_index, service_search) not in calls + + +# ── Hard scope limit: docblocks are NOT read ───────────────────────────────── + +def test_docblock_alone_still_emits_no_edge(tmp_path: Path): + """`@var` docblocks stay out of scope (#38): with no constructor assignment + to corroborate it, a docblock-only property types no receiver.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " /** @var LeadHunterService */\n" + " protected $leadHunter;\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), "a docblock is not evidence — no edge" + + +def test_docblock_does_not_override_the_assigned_type(tmp_path: Path): + """When the docblock and the assigned parameter disagree, the SIGNATURE + wins — proof the docblock is never parsed.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " /** @var \\App\\Audit\\AuditLog */\n" + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $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 + + +# ── Refusals: the binding must be provably single-typed ─────────────────────── + +def test_second_assignment_of_a_different_type_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc, AuditLog $log) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter = $log;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), \ + "two conflicting parameter types poison the property — no edge to EITHER" + + +def test_second_assignment_of_an_untypable_value_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter = app('lead.hunter');\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), \ + "a container resolve names no type — the property is poisoned" + + +def test_assignment_from_an_untyped_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct($leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_from_a_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService|AuditLog $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), "a union names several classes — refuse" + + +def test_assignment_from_a_local_new_emits_no_edge(tmp_path: Path): + """Scope limit: only a typed constructor PARAMETER binds. A local built in + the body is not a parameter, and it poisons rather than binds.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct() {\n" + " $svc = new LeadHunterService();\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_from_an_inline_new_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct() {\n" + " $this->leadHunter = new LeadHunterService();\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_param_rebound_in_the_body_poisons_the_property(tmp_path: Path): + """The parameter's own binding must survive the body: a rebind to an + untypable value makes the name — and so the property — unresolvable.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $svc = $other;\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_param_rebound_to_another_class_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $svc = new AuditLog();\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_closure_param_shadowing_the_ctor_param_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $fn = function (AuditLog $svc) { return $svc; };\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_inside_a_closure_emits_no_edge(tmp_path: Path): + """A deferred write is not a constructor binding: the closure may run never, + later, or more than once.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->boot = function () use ($svc) { $this->leadHunter = $svc; };\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_inside_a_closure_poisons_a_good_binding(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->boot = function () { $this->leadHunter = null; };\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_augmented_assignment_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter ??= $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_reference_assignment_poisons_the_property(tmp_path: Path): + """`$this->prop = &$svc` aliases the property to the variable's storage, so + a later rebind of that variable changes the property's type too. + + Two statements on purpose: `$this->prop = &$svc` alone is a + `reference_assignment_expression`, which binds nothing whether or not the + refusal exists, so a one-statement fixture could not tell POISONED from + never-bound. The plain assignment first makes the refusal observable. + """ + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter = &$svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_list_destructuring_target_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " [$this->leadHunter, $rest] = $pair;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_foreach_target_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " foreach ($rows as $this->leadHunter) {}\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_in_a_non_constructor_method_emits_no_edge(tmp_path: Path): + """Scope limit: the CONSTRUCTOR body is the binding site. A setter runs at + an unknown time and may be called with anything.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function setHunter(LeadHunterService $leadHunter): void {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_interface_typed_param_assigned_to_a_property_emits_no_edge(tmp_path: Path): + """The interface/enum/trait refusal (#5, #12) applies to a ctor-body-bound + receiver too: an interface names no implementation.""" + calls, r = _calls(tmp_path, { + "app/Contracts/LeadHunterService.php": ( + "leadHunter = $leadHunter;\n" + " }\n", + uses="use App\\Contracts\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_out_of_corpus_param_type_emits_no_edge(tmp_path: Path): + """A `use`-claimed name that names no in-corpus class is decided and never + falls back to the short-name index (#16).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n", + uses="use Vendor\\Sdk\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +# ── The refusal is property-scoped, not constructor-scoped ──────────────────── +# +# A property is not a local: any method of the class can rewrite it, and unlike a +# natively typed property (which PHP fatals on an incompatible write) an untyped +# one carries no runtime guarantee. So "poison on reassignment" is judged over +# the whole CLASS — a write anywhere outside the constructor is the same category +# of deferred write as a closure's, and refuses the constructor's binding. + +def test_setter_reassigning_to_another_type_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function setHunter(AuditLog $log): void {\n" + " $this->leadHunter = $log;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), \ + "a setter rewrites the property to another class — the ctor binding is not " \ + "what the property provably holds at the call site" + + +def test_setter_assigning_an_untypable_value_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(): void {\n" + " $this->leadHunter = app('lead.hunter');\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_setter_assigning_the_same_type_also_refuses(tmp_path: Path): + """Deliberately conservative: refusal is unconditional on the write, not + conditional on the written type. A same-typed setter is provably harmless, + so this costs a little recall — the trade the refusal discipline chooses. + Documented as intended behavior so a future reader does not read it as a bug. + """ + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function setHunter(LeadHunterService $svc): void {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_a_setter_that_leaves_the_property_alone_keeps_the_binding(tmp_path: Path): + """Control for the class-wide sweep: it must refuse only the properties a + method actually WRITES, not every property of a class that has setters.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " protected $other;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function setOther(AuditLog $log): void {\n" + " $this->other = $log;\n" + " $log->search([]);\n" + " }\n" + " public function read(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + read = _find(r, ".read()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (read, service_search) in calls + assert (index, _find(r, ".search()", "audit_auditlog")) not in calls + + +def test_non_constructor_augmented_assignment_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(): void {\n" + " $this->leadHunter ??= null;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_non_constructor_list_destructuring_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(array $pair): void {\n" + " [$this->leadHunter, $rest] = $pair;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_non_constructor_foreach_target_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(array $rows): void {\n" + " foreach ($rows as $this->leadHunter) {}\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_non_constructor_reference_assignment_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(LeadHunterService $other): void {\n" + " $this->leadHunter = &$other;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_write_inside_a_closure_in_another_method_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(): void {\n" + " $this->defer(function () { $this->leadHunter = null; });\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_a_write_to_another_objects_property_does_not_poison(tmp_path: Path): + """`$other->leadHunter = …` writes a DIFFERENT object's property.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function copyTo(LeadController $other): void {\n" + " $other->leadHunter = null;\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_a_write_in_an_anonymous_class_does_not_poison_the_outer_class(tmp_path: Path): + """An anonymous class's `$this` is its OWN instance, so a write to a + same-named property inside one says nothing about the outer class's.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function make(): object {\n" + " return new class {\n" + " public $leadHunter;\n" + " public function boot(): void { $this->leadHunter = null; }\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_a_write_in_another_class_does_not_poison(tmp_path: Path): + """The sweep is class-scoped: a same-named property written by a DIFFERENT + class in the same file leaves this class's binding alone.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $svc;\n" + " }\n" + " public function index(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + "class OtherController {\n" + " protected $leadHunter;\n" + " public function reset(): void {\n" + " $this->leadHunter = null;\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + + +def test_declared_typed_property_survives_a_setter(tmp_path: Path): + """The class-wide sweep must not reach the DECLARED path: PHP enforces a + native property type on every write, so a setter cannot retype it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected LeadHunterService $leadHunter;\n" + " public function setHunter(LeadHunterService $svc): void {\n" + " $this->leadHunter = $svc;\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_promoted_property_survives_a_setter(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(protected LeadHunterService $leadHunter) {}\n" + " public function setHunter(LeadHunterService $svc): void {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +# ── Precedence: a real annotation always outranks the assignment ────────────── + +def test_declared_property_type_outranks_the_ctor_assignment(tmp_path: Path): + """A natively typed property is the author's stated contract; the ctor-body + assignment only fills a GAP.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected AuditLog $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "auditlog")) in calls + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + + +def test_union_typed_property_declaration_stays_refused(tmp_path: Path): + """A union annotation is a PRESENT-but-unresolved binding (#9), not a gap: + the ctor assignment must not resolve it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected LeadHunterService|AuditLog $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_promoted_param_outranks_a_conflicting_ctor_assignment(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(\n" + " protected AuditLog $leadHunter,\n" + " LeadHunterService $svc\n" + " ) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "auditlog")) in calls + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + + +# ── Regressions: the two already-covered sibling forms keep working ─────────── + +def test_regression_promoted_param_receiver_still_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(protected readonly LeadHunterService $leadHunter) {}\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_regression_typed_property_receiver_still_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " private LeadHunterService $leadHunter;\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_regression_typed_method_param_receiver_still_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $leadHunter;\n" + " }\n" + " public function handle(LeadHunterService $svc): array {\n" + " return $svc->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 From 1606462172f6611461deb374f04b74566640936b Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Fri, 7 Aug 2026 11:02:12 -0300 Subject: [PATCH 36/41] chore: bump to 0.9.38; changelog for the stub-shadow + call-edge fixes (#46/#47/#48/#49/#38/#39) Moves the five fix bullets the implementers filed under 0.9.37 into a new 0.9.38 section (0.9.37 is the currently-installed pre-fix version) and adds the missing #39 bullet. The bump is load-bearing, not cosmetic: the AST cache is version-namespaced, so a same-version rebuild replays pre-fix cached nodes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 +++++++++++------ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef835fd52..359d00c14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,23 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.38 (unreleased) + +- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. +- Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. A PHP corpus must therefore be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. +- Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. +- Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. +- Fix: a PHP `parent::`, `self::` or `static::` call no longer names its **scope** as the callee, so it can no longer bind to an unrelated method that merely shares that word as a label (`lawnstarter/graphify#39`). The `scoped_call_expression` handler read the scope text as the callee name — right for `Helper::format()`, wrong for the three relative scopes: `parent::setUp()` minted a raw call to a callee literally named `parent`, and since no in-file definition answers that name, the fact reached the cross-file pass, which matches by normalized label — so it bound to whatever `->parent()` accessor the corpus happened to hold. On the pinned 46,406-node api.lawnstarter.com corpus that is **1,698 fabricated inbound `calls` edges on `ServiceCategory::parent()`**, a model accessor credited with calling the `setUp()` of test classes across the corpus. Which class a relative scope denotes needs the inheritance context the raw-call facts do not carry, so the extractor refuses rather than guesses, gated on `_PHP_NON_CONCRETE_TYPE_NAMES` — the same set the `(new X())->m()` branch already applies. Absolute scopes (`Helper::format()`, `\App\Foo::bar()`) are unaffected, and the refusal drops the fabricated edge without inventing a replacement: binding a relative scope to its real target needs the resolved base class, which is a separate piece of work. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to shed these edges. +- Fix: a PHP property assigned in the **constructor body** from a typed constructor parameter now types the `$this->prop->method()` receiver, so the call resolves to that parameter's class (`lawnstarter/graphify#38`). PHP receiver typing read the class's typed properties and constructor-promoted params only, which misses the pre-promotion idiom that dominates a Laravel corpus: the property is declared untyped with its type in a `@var` docblock, and the collaborator arrives as a typed `__construct` parameter that the body assigns across (`$this->chargeCustomerService = $chargeCustomerService;`). Measured on the 48.5k-node Laravel corpus, `BalanceCustomerAccountService::handle()` minted no `calls` edge to `ChargeCustomerService::handle()` while its two sibling callers — one using a promoted param, one a typed method param — resolved fine. The **parameter's signature is the evidence and `@var` docblocks are still not read**: a docblock-only property with no constructor assignment types nothing, and where docblock and signature disagree the signature wins. The refusal discipline is the local-variable one: the property is poisoned rather than guessed at when its binding is not provably single-typed — a second assignment of a different or untypable type (a container resolve, an inline `new`, a local, a literal), an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body, including by a shadowing closure parameter. Because a property is not a local, that refusal is judged over the whole **class**, not just the constructor: a write to the property from any other method — a setter, a `boot()`, a closure captured anywhere — refuses the constructor's binding, since it is a deferred write that may run never, later, or repeatedly, and an *untyped* property (unlike a natively typed one, which PHP fatals on an incompatible write) carries no runtime guarantee that would make it safe. The refusal is unconditional on the write and not conditional on the written type, so even a same-typed setter costs the edge — deliberate, and the trade this discipline makes everywhere else too. A real annotation always outranks the assignment, so a natively typed or promoted property keeps its declared type and a union-typed one keeps its multi-class refusal (#9); every downstream refusal — interface/enum/trait receivers (#5, #12), the `use`-claimed out-of-corpus name (#16), the single-definition guard — applies unchanged, since this only fills in the receiver type the resolver was already asking for. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to pick the new edges up. +- One related defect is deliberately NOT fixed here and keeps its own issue: the member-call resolver still REFUSES to bind a receiver typed by an interface, enum or trait (the refusal described in the fork's 0.9.35 section below). The collision it was built for — `App\Contracts\Notifier` beside `App\Support\Notifier` — now presents two definitions and would be refused by the single-definition guard on its own, so the pre-scan is now redundant conservatism rather than a workaround for a missing node, but lifting it is a behavior change on its own merits. That fan-in stays unrealized for now. The second defect `#47` originally deferred — an `imports` edge whose target never got a stub node — is not deferred after all: `lawnstarter/graphify#48` ships in this same release (bullet above), and the two compose exactly as anticipated, since `#47` supplies the declaration node `#48`'s resolution needs to land on. +- The package version bump to 0.9.38 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does, and here that roll is load-bearing rather than incidental: four of the five fixes above are extraction-side (only `#49` is serve-side), and AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild would replay the pre-fix nodes untouched. A PHP corpus must be re-extracted (`graphify update .`) to pick this release up; the graph format itself is unchanged. + ## 0.9.37 (unreleased) - Sync: upstream `Graphify-Labs/graphify` **0.9.35** is absorbed into the fork (upstream `9f25a3a`, tag `v0.9.35`). Its `build_merge` shrink-guard and absolute-path prune fixes (#2497, #2446), the `graphify update` newly-ignored-file eviction and Java local-class/external-annotation split (#2495, #2504), and the direction-aware `graphify callflow` (#2508) are taken wholesale — the fork had no competing edits there. Every upstream 0.9.35 bullet is reproduced verbatim in its own section below. - Sync: upstream's own take on the relational-verb seeding defect (#2507, filed from this fork) collided with the fork's `lawnstarter/graphify#37` C1 fix in `graphify/serve.py`; the fork's structure wins and upstream's vocabulary is absorbed into it, so no demotion either side shipped is lost. Upstream demotes a hardcoded verbs-only list inline at the `_query_graph_text` call site; the fork derives `_RELATIONAL_INTENT_TERMS` from the two tables that already encode relational intent and demotes through the `_demote_relational_intent_terms` helper, which keeps the map-keyed all-relational fallback (`graphify query "calls"` keeps its guarantee) and composes with the fork's `skip_covered_terms` (#37/C2) and heuristic-filter relaxation (#37/C3). The derived set now unions in the twelve words upstream demoted that neither table yields — `depend`, `depends`, `export`, `extend`, `extends`, `extended`, `implement`, `implements`, `implemented`, `reference`, `references`, `referenced` — making the fork's vocabulary a strict superset of both parents' (62 words vs upstream's 29). Upstream's five #2507 tests are adopted verbatim and pass against the fork's implementation unchanged. - Behavior change: **`caller` and `callers` now infer the `call` context filter**, adopting upstream's `_CONTEXT_HINTS` addition. This supersedes the "the hint table itself is untouched, so which queries get context-filtered is unchanged" note in 0.9.36 below: "callers of X" now takes the filtered path that "who calls X" already took. On a bare class node that is exactly the stranding case #37/C3 was built for — a class owns no `call` edges — so the filter reaches nothing beyond the seeds, the traversal relaxes to unfiltered, and the header says `Context: call (heuristic; relaxed — no matches beyond seeds)`. The answer is unchanged in content (all 3 callers still render on the 48.5k-node Laravel corpus); only the header note is new. Upstream shipped this hint without the relaxation fallback, so on upstream the same query strands; on the fork it does not. - Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip 0.9.36 shipped (`lawnstarter/graphify#37` C2; adopted from the `Graphify-Labs#2516` review, upstream `234c5f0`). 0.9.36 let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. -- Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. -- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. -- Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. This supersedes the "Nothing in this release changes extraction, merge or the graph format on the fork's side" clause in the version-bump note below: a PHP corpus must be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. -- Two related defects are deliberately NOT fixed here and keep their own issues. An `imports` edge whose target never got a stub node — a type used only via `Foo::class`, which hits classes too — is still skipped by the repoint pass and still dangles, because that pass reads the target's label off a node that does not exist rather than off the edge's own `target_fqn` metadata (`lawnstarter/graphify#48`); this change supplies the declaration node that fix needs to land on, so the two compose. And the member-call resolver still REFUSES to bind a receiver typed by an interface, enum or trait (the refusal described in the fork's 0.9.35 section below): the collision it was built for — `App\Contracts\Notifier` beside `App\Support\Notifier` — now presents two definitions and would be refused by the single-definition guard on its own, so the pre-scan is now redundant conservatism rather than a workaround for a missing node, but lifting it is a behavior change on its own merits. That fan-in stays unrealized for now. -- Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. -- Fix: a PHP property assigned in the **constructor body** from a typed constructor parameter now types the `$this->prop->method()` receiver, so the call resolves to that parameter's class (`lawnstarter/graphify#38`). PHP receiver typing read the class's typed properties and constructor-promoted params only, which misses the pre-promotion idiom that dominates a Laravel corpus: the property is declared untyped with its type in a `@var` docblock, and the collaborator arrives as a typed `__construct` parameter that the body assigns across (`$this->chargeCustomerService = $chargeCustomerService;`). Measured on the 48.5k-node Laravel corpus, `BalanceCustomerAccountService::handle()` minted no `calls` edge to `ChargeCustomerService::handle()` while its two sibling callers — one using a promoted param, one a typed method param — resolved fine. The **parameter's signature is the evidence and `@var` docblocks are still not read**: a docblock-only property with no constructor assignment types nothing, and where docblock and signature disagree the signature wins. The refusal discipline is the local-variable one: the property is poisoned rather than guessed at when its binding is not provably single-typed — a second assignment of a different or untypable type (a container resolve, an inline `new`, a local, a literal), an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body, including by a shadowing closure parameter. Because a property is not a local, that refusal is judged over the whole **class**, not just the constructor: a write to the property from any other method — a setter, a `boot()`, a closure captured anywhere — refuses the constructor's binding, since it is a deferred write that may run never, later, or repeatedly, and an *untyped* property (unlike a natively typed one, which PHP fatals on an incompatible write) carries no runtime guarantee that would make it safe. The refusal is unconditional on the write and not conditional on the written type, so even a same-typed setter costs the edge — deliberate, and the trade this discipline makes everywhere else too. A real annotation always outranks the assignment, so a natively typed or promoted property keeps its declared type and a union-typed one keeps its multi-class refusal (#9); every downstream refusal — interface/enum/trait receivers (#5, #12), the `use`-claimed out-of-corpus name (#16), the single-definition guard — applies unchanged, since this only fills in the receiver type the resolver was already asking for. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to pick the new edges up. - The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. ## 0.9.36 (unreleased) diff --git a/pyproject.toml b/pyproject.toml index 8b184ac226..bfb6462272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.37" +version = "0.9.38" 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 44e05f4ab5..91c995831f 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.37" +version = "0.9.38" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From 9069df5087a4492f8640552b5bae0146f4a05154 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Fri, 7 Aug 2026 13:46:07 -0300 Subject: [PATCH 37/41] fix(extract): PHP function-call sites never bare-match method or class nodes cross-file (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PHP `function_call_expression` — a bare `name(...)` — can only invoke a global or namespaced function. Reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, and a class-like declaration is not invocable at all (`Report($e)` with only `class Report` in scope is a "Call to undefined function" fatal, not a constructor call). The shared cross-file pass matched by normalized label, and the label index strips the member marker (`.event()` and `event()` both key as `event`), so Laravel's `event(...)` helper bound to whichever class declared an `event()` METHOD — 848 incoming `calls` edges on a single test method in the measured corpus, as a single-candidate bind before any tie-break could apply. A CLASS reached the same way through the case-insensitive fold (`foo(...)` → `class Foo`). The PHP extractor now tags function-call raw_calls (`php_function_call`), which the raw-call facts otherwise cannot tell from a `scoped_call` — also not a member call. The cross-file pass keeps only plausible function targets for those sites, dropping member-labelled and class-like candidates, and filters the candidate LIST rather than re-keying the index so the refusal covers every consumer at once: single-candidate bind, import-evidence disambiguation and the god-node tie-break, on both the exact-case and the case-insensitive path. The folded lookup is retried after the refusal, not skipped: it only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise hide a real `function Event()` reachable solely under the folded key. The retry carries the refusal with it — without that it offers a capitalized class under the shadowed lowercase key that the exact-case path never had. Both discriminators survive an incremental rebuild: the member marker rides the persisted label, and `_callable_class` is replayed onto `resolution_context_nodes` by `graphify update` and `watch`. Real global functions still resolve, and candidate filtering for every other language is unchanged — Ruby and Python bare calls reach methods through implicit self, so a method stays a plausible target there. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + graphify/extract.py | 54 ++++ graphify/extractors/engine.py | 12 + tests/test_php_function_call_never_method.py | 275 +++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 tests/test_php_function_call_never_method.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 359d00c14c..f4ed12f302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.39 (unreleased) + +- Fix: a PHP **function call never binds to a method** across files (`lawnstarter/graphify#52`). In PHP a bare `name(...)` — a `function_call_expression` — can only invoke a global or namespaced *function*; reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, each of which takes a different path through the extractor. The shared cross-file pass matched by normalized label, and that normalization (`raw.strip("()").lstrip(".")`) erases the member marker the engine writes — a method labeled `.event()` and a function labeled `event()` both key as `event` — so Laravel's `event(...)` helper bound to whichever class happened to declare an `event()` method. On the pinned 46,406-node api.lawnstarter.com corpus that is **848 fabricated inbound `calls` edges on `GetProviderBillingEventsTest::event()`**, a test method credited as the callee of 368 distinct source files, 308 of them not tests. It was not even an ambiguity the god-node tie-breakers could catch: the three `Event` *classes* key as `Event`, so the method was the **sole** exact-case candidate and bound through the single-candidate path at INFERRED 0.8, before any tie-break could apply. The refusal is language-semantic rather than heuristic — a method is simply not a candidate at a function-call site — so it is applied to the candidate **list**, not by re-keying the indexes, which covers every consumer at once: the single-candidate bind, the symbol/module import-evidence disambiguation, and the god-node tie-break. The same language-semantic argument extends past methods: a **class-like declaration is not invocable either** — `report($e)` where only `class Report` exists is a "Call to undefined function" fatal, not a constructor call — so class, interface, enum and trait nodes are refused at a function-call site alongside methods, leaving only plausible function targets. That half also fixes a pre-existing bind this release would otherwise have inherited: `foo(...)` matching a cross-file `class Foo` through the case-insensitive fold has always produced a `calls` edge into the class. Both index paths are covered, and the folded lookup is *retried* after the method/class refusal rather than skipped: the case-insensitive fallback only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise have hidden a real `function Event()` reachable solely under the folded key, and the site would have resolved to nothing at all. The retry has to carry the refusal with it, or it hands the folded index a candidate the exact-case path never offered — a capitalized class under the shadowed lowercase key, which is exactly the **7 `report(...)` → `class Checkr\Resources\Report` edges** measured on the rebuilt 46.6k-node corpus. The class discriminator is the `_callable_class` marker the `indirect_call` guard already reads, chosen for the same reason as the label one: it is replayed onto `resolution_context_nodes` by both `graphify update` and `watch`, so it still classifies unchanged-corpus candidates on an incremental rebuild. The call site is identified by a marker the PHP extractor stamps on the raw call, which is load-bearing rather than convenience: a `scoped_call_expression` is also not a member call, so refusing on "PHP and not a member call" alone would have silently widened the policy to `Class::method()`. The method-vs-function discriminator is the engine's own label convention (`.name()` for a member, `name()` for a top-level function), chosen because a label persists into `graph.json` and so still classifies the unchanged-corpus candidates an incremental rebuild hands back as resolution context (#2406) — an edge-derived test would not, since the shared pass never receives those edges. PHP-only by construction: a Ruby or Python bare call reaches a method through implicit self, so their candidate filtering is deliberately untouched, as is the member-call resolver (`_resolve_php_member_calls`). One residual is documented rather than chased: a `event(...)` call in the *same file* as a class declaring an `event()` method still binds to it, because the extractor resolves that raw call in-file and it never reaches the cross-file pass — unchanged behavior, and the reason the measured fan-in shrinks to near-zero rather than provably zero. Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to shed these edges, and re-extraction genuinely requires the release's version bump to roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`): entries are keyed by content hash *within* that namespace, so a same-version rebuild replays the pre-fix raw calls untouched. + ## 0.9.38 (unreleased) - Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. diff --git a/graphify/extract.py b/graphify/extract.py index db5d85137a..f4db56d3b5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6012,11 +6012,22 @@ def _looks_like_bash(result: object) -> bool: n for n in resolution_context_nodes if n.get("id") and n["id"] not in _fresh_ids ] + # #52: ids of nodes whose label marks them as a MEMBER of a type — the + # engine labels a method `.name()` and a property `.name`, against a + # top-level function's `name()`. The normalization below erases that marker + # (`.event()` and `event()` both key as `event`), which is what let a PHP + # `event(...)` function call bind to a class's `event()` METHOD. The label + # is persisted in graph.json, so the discriminator holds just as well for + # candidates handed back as unchanged-corpus resolution context (#2406) — + # unlike an id-shape or edge-derived test. + member_labelled_nids: set[str] = set() for n in resolution_nodes: if n.get("file_type") == "rationale" or n.get("type") == "namespace": continue raw = n.get("label", "") normalised = raw.strip("()").lstrip(".") + if raw.strip("()").startswith("."): + member_labelled_nids.add(n["id"]) if normalised: # Case is semantic in most languages, so index (and match, below) by exact # case — folding collapses `Path` (class) into `PATH` (env var) and makes a @@ -6039,6 +6050,20 @@ def _looks_like_bash(result: object) -> bool: # them from the indirect_call guard below to avoid false edges (#2137). class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")} + def _is_php_function_target(nid: str) -> bool: + """Can a bare PHP ``name(...)`` reach this candidate? (#52) + + Only a global or namespaced FUNCTION. A METHOD needs ``$obj->`` / + ``Class::`` / callable syntax, and a CLASS-LIKE declaration (class, + interface, enum, trait) is not invocable at all — ``Report($e)`` where + only ``class Report`` exists is a "Call to undefined function" fatal, + not a constructor call. Both discriminators survive an incremental + rebuild: the member marker rides the persisted LABEL, and + ``_callable_class`` is one of the markers `graphify update` / `watch` + replay onto their `resolution_context_nodes` (cli.py, watch.py). + """ + return nid not in member_labelled_nids and nid not in class_nids + # Build evidence index from import edges so cross-file calls backed by an # explicit import statement can be promoted from INFERRED to EXTRACTED. # Direct symbol imports (`import { foo }` / `const { foo } = require()`) are @@ -6142,6 +6167,35 @@ def _looks_like_bash(result: object) -> bool: candidates = global_label_to_nids_ci.get(callee.lower(), []) if not candidates: continue + # #52: a PHP `function_call_expression` — a bare `name(...)` — can only + # invoke a global/namespaced FUNCTION, so only plausible function targets + # stay candidates (`_is_php_function_target`). Laravel's `event(...)` + # helper was binding to whichever class declared an `event()` METHOD — + # 848 incoming edges on one test method in the measured corpus — because + # the label index erases the `.` member marker and the method was then + # the sole candidate → single-candidate bind. Filtering the candidate + # list (rather than the index) covers every consumer below at once — + # single-candidate bind, import-evidence disambiguation and the god-node + # tie-break. PHP-only by design: Ruby and Python bare calls reach methods + # through implicit self, so their candidate filtering is unchanged. + if rc.get("php_function_call"): + candidates = [c for c in candidates if _is_php_function_target(c)] + # Refusing the exact-case candidates re-opens the folded fallback, + # which only ever fires on an EMPTY exact-case list above. A method + # shadowing the exact-case key (`.event()` → `event`) otherwise hid a + # real `function Event()` — reachable solely under the folded key — + # and the site resolved to nothing at all. Same folded index, same + # case-insensitive-caller gate, same refusal applied to it: without + # the refusal here the retry reaches a capitalized CLASS under the + # folded key (`report(...)` → `class Report`) that the exact-case + # path never offered — 7 such edges on the measured corpus. + if not candidates and _lang_is_case_insensitive(rc.get("source_file")): + candidates = [ + c for c in global_label_to_nids_ci.get(callee.lower(), []) + if _is_php_function_target(c) + ] + if not candidates: + continue # Cross-language guard: never bind a call to a definition in a different # language family. Name-only matching was resolving a TSX callback passed # by name to a same-named Kotlin method in the Android half of the repo diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 1d4396af16..bc229226bc 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4903,6 +4903,13 @@ def walk_calls( # reference to the method, not an invocation — re-tagged as # `indirect_call` below and by the cross-file PHP resolver. php_fcc: bool = False + # PHP `function_call_expression`, i.e. a bare `name(...)` (#52). It + # can only invoke a global/namespaced FUNCTION — a method needs + # `$obj->`, `Class::` or callable syntax — so the cross-file pass + # refuses method candidates for it. Recorded here because the + # raw-call facts otherwise cannot tell it from a `scoped_call` + # (which is also not a member call and names its scope as callee). + php_function_call: bool = False # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -5015,6 +5022,7 @@ def walk_calls( elif config.ts_module == "tree_sitter_php": # PHP: distinguish call expression subtypes if node.type == "function_call_expression": + php_function_call = True func_node = node.child_by_field_name("function") if func_node: callee_name = _read_text(func_node, source) @@ -5363,6 +5371,10 @@ def walk_calls( # stamp the receiver type resolved above (#1682). if config.ts_module == "tree_sitter_php": rc_entry["lang"] = "php" + if php_function_call: + # Marker read by the shared cross-file pass, which + # drops METHOD candidates for this call site (#52). + rc_entry["php_function_call"] = True if php_fcc: # Marker read by _resolve_php_member_calls, which # emits `indirect_call` for it under the SAME diff --git a/tests/test_php_function_call_never_method.py b/tests/test_php_function_call_never_method.py new file mode 100644 index 0000000000..4916ff393e --- /dev/null +++ b/tests/test_php_function_call_never_method.py @@ -0,0 +1,275 @@ +"""A PHP function-call site never binds to a METHOD node cross-file (#52). + +In PHP a bare ``name(...)`` (``function_call_expression``) can only invoke a +global or namespaced *function*. Reaching a method requires ``$obj->m()``, +``Class::m()`` or first-class-callable syntax. The shared cross-file pass in +``extract()`` matched by normalized label, and the label index strips the +member marker (``.event()`` -> ``event``), so Laravel's ``event(...)`` helper +bound to whatever class happened to declare an ``event()`` METHOD — 848 +incoming ``calls`` edges on a single test method in the measured corpus. + +Refusal is language-semantic, not a heuristic: methods are simply not +candidates at a function-call site. Real global functions still resolve, and +no other language's candidate filtering changes (Ruby/Python bare calls keep +today's behavior — implicit-self dispatch makes a method a plausible target +there). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + +CALLER = ( + " source) and return (result, {(src, tgt): edge}).""" + 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 result, calls + + +def _find(result: dict, label: str, id_contains: str) -> str: + nid = next( + ( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ), + None, + ) + assert nid is not None, f"no node {label}/{id_contains}" + return nid + + +def test_function_call_does_not_bind_to_method(tmp_path): + """`event(...)` + a class method `event()` elsewhere -> no `calls` edge.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/Decoy.php": DECOY_METHOD, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + assert (caller, method) not in calls + + +def test_function_call_still_binds_to_global_function(tmp_path): + """The legitimate global-function resolution must not regress.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + function = _find(result, "event()", "helpers") + assert (caller, function) in calls + + +def test_function_wins_when_method_and_function_both_exist(tmp_path): + """With both candidates present the call binds to the function, never the method.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/Decoy.php": DECOY_METHOD, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + function = _find(result, "event()", "helpers") + assert (caller, method) not in calls + assert (caller, function) in calls + + +def test_case_insensitive_path_does_not_bind_to_method(tmp_path): + """`EVENT(...)` matches `.event()` only via the folded index — still refused.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_FOLDED, + "app/Decoy.php": DECOY_METHOD, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + assert (caller, method) not in calls + + +def test_case_insensitive_path_still_binds_to_global_function(tmp_path): + """PHP is case-insensitive: `EVENT(...)` still resolves to `function event()`.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_FOLDED, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + function = _find(result, "event()", "helpers") + assert (caller, function) in calls + + +def test_case_insensitive_function_wins_over_method(tmp_path): + """Folded path, both candidates: the function is picked, the method refused.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_FOLDED, + "app/Decoy.php": DECOY_METHOD, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + function = _find(result, "event()", "helpers") + assert (caller, method) not in calls + assert (caller, function) in calls + + +def test_case_mismatched_function_resolves_when_method_shadows_exact_key(tmp_path): + """The folded index is retried after the refusal, not skipped. + + `event(...)` finds the METHOD `.event()` on the exact-case key, so the + folded fallback never fires on its own; refusing that sole candidate must + not lose `function Event()`, which PHP's case-insensitive function lookup + makes the real target and which only exists under the folded key. + """ + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/Decoy.php": DECOY_METHOD, + "app/helpers.php": GLOBAL_FUNCTION_FOLDED, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + function = _find(result, "Event()", "helpers") + assert (caller, method) not in calls + assert (caller, function) in calls + + +def test_function_call_does_not_bind_to_class_through_folded_retry(tmp_path): + """A CLASS is no more invocable by `name(...)` than a method is. + + `report($e)` with a `.report()` method holding the exact-case key: the + refusal empties that list, and the folded retry then reaches the + capitalized `class Report` — a candidate the exact-case path never offered. + """ + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_REPORT, + "app/Decoy.php": DECOY_REPORT_METHOD, + "app/Report.php": CLASS_REPORT, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".report()", "decoy") + klass = _find(result, "Report", "report") + assert (caller, klass) not in calls + assert (caller, method) not in calls + + +def test_function_call_does_not_bind_to_class_unshadowed(tmp_path): + """`foo(...)` + only `class Foo` cross-file -> no edge (no method involved).""" + result, calls = _extract(tmp_path, { + "app/Caller.php": ( + " Date: Fri, 7 Aug 2026 13:45:45 -0300 Subject: [PATCH 38/41] fix(serve): scored endpoint resolution prefers sourced nodes over sourceless stubs (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_find_node_tiers` drops sourceless stubs from a mixed exact tier (#49), so `explain` and `affected` resolve a shadowed label to its real declaration. The scored path `graphify query` / `shortest_path` run on never learned that rule: a stub and a sourced declaration sharing a label score identically (5619.08 on the two-node repro), so `_score_nodes`' sort fell through to its node-id tie-break and `_pick_scored_endpoint` / `_pick_seeds` answered with whichever id sorted first — violating the design intent stated at serve.py:515-520 that `path`/`query` resolve the same node `explain` does. A stub can never out-score an otherwise-identical sourced node (the source-file tier only ever adds), so a shadowing stub always arrives as an exact score tie. Insert the sourced preference directly under the score in both tie-break keys (the combined ranking sort and the per-term singleton winner), above the label-length and node-id keys, which are arbitrary with respect to which node is real. #49's carve-out is preserved by construction: an all-sourceless field has nothing to prefer, so a lone stub still wins its query. `_find_node`'s existing behavior is untouched. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/serve.py | 75 ++++-- ...test_scored_endpoint_sourced_preference.py | 236 ++++++++++++++++++ 3 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 tests/test_scored_endpoint_sourced_preference.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ed12f302..230017882e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.39 (unreleased) - Fix: a PHP **function call never binds to a method** across files (`lawnstarter/graphify#52`). In PHP a bare `name(...)` — a `function_call_expression` — can only invoke a global or namespaced *function*; reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, each of which takes a different path through the extractor. The shared cross-file pass matched by normalized label, and that normalization (`raw.strip("()").lstrip(".")`) erases the member marker the engine writes — a method labeled `.event()` and a function labeled `event()` both key as `event` — so Laravel's `event(...)` helper bound to whichever class happened to declare an `event()` method. On the pinned 46,406-node api.lawnstarter.com corpus that is **848 fabricated inbound `calls` edges on `GetProviderBillingEventsTest::event()`**, a test method credited as the callee of 368 distinct source files, 308 of them not tests. It was not even an ambiguity the god-node tie-breakers could catch: the three `Event` *classes* key as `Event`, so the method was the **sole** exact-case candidate and bound through the single-candidate path at INFERRED 0.8, before any tie-break could apply. The refusal is language-semantic rather than heuristic — a method is simply not a candidate at a function-call site — so it is applied to the candidate **list**, not by re-keying the indexes, which covers every consumer at once: the single-candidate bind, the symbol/module import-evidence disambiguation, and the god-node tie-break. The same language-semantic argument extends past methods: a **class-like declaration is not invocable either** — `report($e)` where only `class Report` exists is a "Call to undefined function" fatal, not a constructor call — so class, interface, enum and trait nodes are refused at a function-call site alongside methods, leaving only plausible function targets. That half also fixes a pre-existing bind this release would otherwise have inherited: `foo(...)` matching a cross-file `class Foo` through the case-insensitive fold has always produced a `calls` edge into the class. Both index paths are covered, and the folded lookup is *retried* after the method/class refusal rather than skipped: the case-insensitive fallback only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise have hidden a real `function Event()` reachable solely under the folded key, and the site would have resolved to nothing at all. The retry has to carry the refusal with it, or it hands the folded index a candidate the exact-case path never offered — a capitalized class under the shadowed lowercase key, which is exactly the **7 `report(...)` → `class Checkr\Resources\Report` edges** measured on the rebuilt 46.6k-node corpus. The class discriminator is the `_callable_class` marker the `indirect_call` guard already reads, chosen for the same reason as the label one: it is replayed onto `resolution_context_nodes` by both `graphify update` and `watch`, so it still classifies unchanged-corpus candidates on an incremental rebuild. The call site is identified by a marker the PHP extractor stamps on the raw call, which is load-bearing rather than convenience: a `scoped_call_expression` is also not a member call, so refusing on "PHP and not a member call" alone would have silently widened the policy to `Class::method()`. The method-vs-function discriminator is the engine's own label convention (`.name()` for a member, `name()` for a top-level function), chosen because a label persists into `graph.json` and so still classifies the unchanged-corpus candidates an incremental rebuild hands back as resolution context (#2406) — an edge-derived test would not, since the shared pass never receives those edges. PHP-only by construction: a Ruby or Python bare call reaches a method through implicit self, so their candidate filtering is deliberately untouched, as is the member-call resolver (`_resolve_php_member_calls`). One residual is documented rather than chased: a `event(...)` call in the *same file* as a class declaring an `event()` method still binds to it, because the extractor resolves that raw call in-file and it never reaches the cross-file pass — unchanged behavior, and the reason the measured fan-in shrinks to near-zero rather than provably zero. Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to shed these edges, and re-extraction genuinely requires the release's version bump to roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`): entries are keyed by content hash *within* that namespace, so a same-version rebuild replays the pre-fix raw calls untouched. +- Fix: `graphify query` and `graphify path` / `shortest_path` no longer resolve an endpoint to a **sourceless stub** when a real declaration carries the same label (`lawnstarter/graphify#54`, closing the gap `#49` left behind). `#49` taught `_find_node_tiers` and `affected`'s `resolve_seed` to drop stubs from a mixed exact tier, so `explain` and `affected` agree on the sourced declaration — but the scored path those two commands do not share, the `_score_nodes` ranking that feeds `_pick_scored_endpoint` (path) and `_pick_seeds` (query), never learned the rule. On the two-node repro — `FooRepository` at `source_file: ""` beside `FooRepository` at `app/bindings.php` — both nodes score **5619.081861425897**, identically, so the ranking fell through to its lexicographic node-id tie-break and answered with whichever id sorted first: `explain` named the declaration while `query` traversed the stub, reinstating for `explain`-vs-`query` exactly the divergence `#49` closed for `explain`-vs-`affected`, and violating the design intent the scorer's own comment states — that `path` and `query` resolve the same node `explain` does. The stub is rarely a merely cosmetic mismatch, because it is usually the *disconnected* node: `shortest_path` answered a reachable query with a false "No directed path found", and `query` spent its traversal on the stub's neighborhood instead of the declaration's. The fix is a **tie-break, not a scoring penalty**, and the distinction is load-bearing: a sourceless node can never *out*-score an otherwise-identical sourced one, since the only source-derived scoring term is additive and source hits do not count toward term coverage, so a shadowing stub always arrives at the sort as an exact tie and a tie-break is the complete and minimal intervention. The sourced preference is inserted directly under the score in both tie-break keys — the combined ranking sort and the per-term singleton-winner key that feeds the seed guarantee — above the label-length and node-id keys, which are arbitrary with respect to which node is real. No live score changes, so every score-sensitive baseline holds unedited. `#49`'s carve-out survives by construction rather than by a special case: an all-sourceless field has nothing to prefer, so a lone stub still wins its query and nothing that answered before now returns nothing. One asymmetry with `_find_node` is deliberate and left in place, since `_find_node` is unchanged here: it demotes stubs in its *exact* tier only, while a score tie can also arise from the prefix and substring tiers — on those shapes the scored path now prefers the sourced node where `_find_node` still answers by graph-iteration order, which is the more deterministic of the two answers, and closing the remaining gap would mean changing `_find_node` itself. Serve-side only: nothing in extraction, merge or the graph format changes, so an existing graph picks this up with **no rebuild and no re-extraction**. ## 0.9.38 (unreleased) diff --git a/graphify/serve.py b/graphify/serve.py index 4726a523ed..0b1f019ef8 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -425,6 +425,21 @@ class _QueryScores(NamedTuple): best_seed_by_term: dict[str, str] +def _is_sourceless(data: dict) -> bool: + """True when a node carries no `source_file` — extractor placeholder, not a + declaration. + + A sourceless node is a stub the extractor minted for a reference it could not + resolve (an unresolved base type, a dangling import target); serve also + materializes attributeless nodes for dangling edge endpoints, which have no + `source_file` key at all. Either way, when a real sourced declaration carries + the same label the stub is a broken duplicate of it and never the better + answer — the same presence test (never a count threshold) `_find_node_tiers` + applies to its exact tier (#49). + """ + return not str(data.get("source_file") or "") + + def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: """Combined query scorer returning the existing ranked `(score, node_id)` list. @@ -442,9 +457,11 @@ def _score_query( """Single-pass combined scorer that optionally also records the best seed for each normalized query token. - The combined ranking is byte-identical to what `_score_nodes` produced - before the refactor; `_score_nodes` is now a thin wrapper that asks for - `collect_per_term_seeds=False` and returns only `.ranked`. + The combined ranking is the one `_score_nodes` has always returned — the + single-pass refactor left it byte-identical, and the only deliberate change + since is #54's sourced-preference tie-break in the sort below, which reorders + exact score ties and nothing else. `_score_nodes` is now a thin wrapper that + asks for `collect_per_term_seeds=False` and returns only `.ranked`. When `collect_per_term_seeds=True`, the per-token singleton winner is computed alongside the combined score in the *same* per-node visit (it @@ -453,14 +470,17 @@ def _score_query( straight into `_pick_seeds` and skip the T additional whole-graph rescoring passes the old per-token `_score_nodes([token])` loop ran. - Singleton-winner semantics match the legacy per-token path exactly. The - score itself mirrors `_score_nodes([token])` with `n_terms == 1` (so the - coverage term is 1 and the per-token tier is unscaled) plus the broader - joined-singlet tier (which also checks `label_tokens` and `nid_lower`). - Tie-break order is (1) highest singleton score, (2) highest graph degree, - (3) shortest displayed label, (4) lexicographically smallest node id — - exactly what `max(tied, key=degree)` over a sort by `(-score, label_len, - nid)` produced in the legacy `_pick_seeds` per-token loop. The combined + Singleton-winner semantics match the legacy per-token path, with #54's + sourced preference layered on top of it. The score itself mirrors + `_score_nodes([token])` with `n_terms == 1` (so the coverage term is 1 and + the per-token tier is unscaled) plus the broader joined-singlet tier (which + also checks `label_tokens` and `nid_lower`). Tie-break order is (1) highest + singleton score, (2) a sourced node over a sourceless stub, (3) highest + graph degree, (4) shortest displayed label, (5) lexicographically smallest + node id. Everything but (2) is exactly what `max(tied, key=degree)` over a + sort by `(-score, label_len, nid)` produced in the legacy `_pick_seeds` + per-token loop; (2) is #54's, and sits where the combined sort below puts + it — directly under the score, above degree. The combined trigram candidate set (needles `norm_terms + [joined]`) is a superset of each per-token `[t]` candidate set, so iterating combined candidates discovers every non-zero singleton-score node for every term. @@ -588,8 +608,14 @@ def _score_query( # Tie-break key mirrors the legacy sort+max(degree): # (-singleton, -degree, label_len, nid) — the minimum # tuple wins, exactly matching max(tied, key=degree) - # over (label_len asc, nid asc)-sorted ties. - key = (-singleton, -G.degree(nid), len(data.get("label") or nid), nid) + # over (label_len asc, nid asc)-sorted ties — with the + # sourced preference (#54) inserted directly under the + # score, exactly where the combined sort below puts it, so + # a term's per-token winner cannot be a stub that a sourced + # rival ties with. Same carve-out: an all-sourceless field + # still yields a winner. + key = (-singleton, _is_sourceless(data), -G.degree(nid), + len(data.get("label") or nid), nid) cur = best_by_term.get(t) if cur is None or key < cur[0]: best_by_term[t] = (key, nid) @@ -597,9 +623,26 @@ def _score_query( score += tiered * (matched / n_terms) ** 2 if score > 0: scored.append((score, nid)) - # Sort by score desc; break ties toward the shorter label so a concise exact - # match beats a longer superset that happens to share the same score. - scored.sort(key=lambda s: (-s[0], len(G.nodes[s[1]].get("label") or s[1]), s[1])) + # Sort by score desc; among equal scores prefer a sourced declaration over a + # sourceless stub (#54), then break remaining ties toward the shorter label so + # a concise exact match beats a longer superset that happens to share the same + # score. + # + # The sourced preference sits directly under the score because a stub can + # never *out*-score an otherwise-identical sourced node — the source-file tier + # above only ever adds — so a shadowing stub always arrives here as an exact + # tie, and both remaining keys (label length, node id) are arbitrary with + # respect to which node is real: on the #54 repro two `FooRepository` nodes + # score 5619.08 apiece and the id sort answered with the stub, while + # `_find_node`/`resolve_seed` answered with the declaration. This is #49's rule + # applied to the tie the scored path actually produces; ordering it above the + # label-length key mirrors `_find_node_tiers`, which drops stubs from the tier + # outright rather than ranking them within it. Ties with no sourced candidate + # at all are untouched, so a lone stub still wins its query (#49's carve-out). + scored.sort(key=lambda s: ( + -s[0], _is_sourceless(G.nodes[s[1]]), + len(G.nodes[s[1]].get("label") or s[1]), s[1], + )) best_seed_by_term: dict[str, str] = {} if collect_per_term_seeds and best_by_term: best_seed_by_term = {t: nid for t, (_key, nid) in best_by_term.items()} diff --git a/tests/test_scored_endpoint_sourced_preference.py b/tests/test_scored_endpoint_sourced_preference.py new file mode 100644 index 0000000000..d596ac6ff1 --- /dev/null +++ b/tests/test_scored_endpoint_sourced_preference.py @@ -0,0 +1,236 @@ +"""The scored-endpoint path must prefer a sourced node over a sourceless stub +(#54, extending #49's rule from `_find_node` to `_score_nodes`). + +`_find_node_tiers` drops sourceless nodes from a mixed exact tier (#49), so +`explain` and `affected` resolve `FooRepository` to the real declaration. The +scored path `graphify query` / `shortest_path` run on — `_score_nodes` ranking, +then `_pick_scored_endpoint` / `_pick_seeds` — never learned that rule: a stub +and a sourced declaration sharing a label score *identically* (5619.08 on the +two-node repro below), so the score sort fell through to its node-id tie-break +and answered with whichever id sorted first. `graphify query` then traversed a +stub's neighborhood and `shortest_path` anchored on a disconnected placeholder +and reported "No path found" — while `explain` confidently named the other node. +The comment at serve.py:515-520 states the design intent this violated: `path` +and `query` resolve the same node `explain` does. + +The carve-out is #49's: the rule demotes a stub against a real declaration, it +does not delete it. When every candidate is sourceless the stub still wins. +""" +from __future__ import annotations + +import pytest +from networkx.readwrite import json_graph + +from graphify.affected import resolve_seed +from graphify.serve import ( + _find_node, + _pick_scored_endpoint, + _query_graph_text, + _score_nodes, + _score_query, + _shortest_path_text, +) + + +LABEL = "FooRepository" +SOURCED_ID = "zzz_src" +SOURCE_FILE = "app/bindings.php" + + +def _stub(index: int = 0, *, omit_source_key: bool = False) -> dict: + """A sourceless stub, id-sorted *ahead* of the sourced node (the repro shape). + + `omit_source_key` reproduces the attributeless node serve materializes for a + dangling edge endpoint (no `source_file` key at all, not an empty one). + """ + node = {"id": f"aaa_stub{index}", "label": LABEL, "community": 0} + if not omit_source_key: + node["source_file"] = "" + return node + + +def _sourced() -> dict: + return {"id": SOURCED_ID, "label": LABEL, "source_file": SOURCE_FILE, + "source_location": "L10", "community": 0} + + +def _load(nodes: list[dict], links: list[dict] | None = None): + return json_graph.node_link_graph( + {"directed": True, "multigraph": False, "graph": {}, + "nodes": nodes, "links": links or []}, + edges="links", + ) + + +def _endpoint(G, query: str = LABEL) -> str: + """Resolve `query` exactly as `shortest_path` resolves each of its endpoints.""" + scored = _score_nodes(G, [t.lower() for t in query.split()]) + assert scored, "precondition: the query must match something" + return _pick_scored_endpoint(G, scored, query) + + +# --- criterion 1: the reviewer's two-node repro ------------------------------ + + +def test_scored_endpoint_prefers_the_sourced_node(): + """Both nodes score 5619.08; the id sort used to hand back the stub.""" + G = _load([_stub(), _sourced()]) + scored = _score_nodes(G, ["foorepository"]) + assert {nid for _s, nid in scored} == {"aaa_stub0", SOURCED_ID} + assert scored[0][0] == pytest.approx(scored[1][0]), "the repro is a score tie" + assert _pick_scored_endpoint(G, scored, LABEL) == SOURCED_ID + + +@pytest.mark.parametrize("stub_count", [1, 2, 3, 18]) +def test_scored_endpoint_prefers_the_sourced_node_at_any_stub_count(stub_count): + """The rule is a presence test on `source_file`, never a count threshold.""" + G = _load([_stub(i) for i in range(stub_count)] + [_sourced()]) + assert _endpoint(G) == SOURCED_ID + + +def test_scored_endpoint_answer_does_not_depend_on_node_order(): + stubs = [_stub(i) for i in range(3)] + forward = _load(stubs + [_sourced()]) + reverse = _load([_sourced()] + list(reversed(stubs))) + assert _endpoint(forward) == _endpoint(reverse) == SOURCED_ID + + +def test_attributeless_dangling_endpoints_are_also_stubs(): + """A dangling edge endpoint has no `source_file` key at all — same rule.""" + G = _load([_stub(i, omit_source_key=True) for i in range(3)] + [_sourced()]) + assert _endpoint(G) == SOURCED_ID + + +# --- criterion 2: a stub with no sourced rival is still an answer ------------ + + +@pytest.mark.parametrize("omit_source_key", [False, True]) +def test_lone_stub_with_no_sourced_rival_still_resolves(omit_source_key): + """#49's carve-out: demoted against a real declaration, never deleted.""" + stub = _stub(0, omit_source_key=omit_source_key) + G = _load([stub]) + assert _endpoint(G) == stub["id"] + + +@pytest.mark.parametrize("stub_count", [2, 18]) +def test_all_sourceless_candidates_still_return_one(stub_count): + """Never return nothing where today something returns.""" + G = _load([_stub(i) for i in range(stub_count)]) + assert _endpoint(G) in {f"aaa_stub{i}" for i in range(stub_count)} + + +def test_query_over_only_stubs_still_traverses_them(): + G = _load( + [_stub(0), {"id": "beta", "label": "BetaService", "source_file": "app/Beta.php", + "community": 0}], + [{"source": "aaa_stub0", "target": "beta", "context": "call"}], + ) + out = _query_graph_text(G, LABEL) + assert "No matching nodes found." not in out + assert "BetaService" in out + + +# --- criterion 3: query / shortest_path agree with explain's _find_node ------ + + +@pytest.mark.parametrize("stub_count", [1, 2, 18]) +def test_scored_path_agrees_with_find_node_and_resolve_seed(stub_count): + G = _load([_stub(i) for i in range(stub_count)] + [_sourced()]) + assert _endpoint(G) == _find_node(G, LABEL)[0] == resolve_seed(G, LABEL) == SOURCED_ID + + +def _repro_graph_with_neighbors(): + """The repro pair, each node in its own component. + + `AlphaService` hangs off the sourced declaration, `BetaService` off the stub, + so which endpoint got picked is visible in the traversal output and in + whether `shortest_path` finds a path at all. Both edges point away from the + `FooRepository` node so the directed defaults of `query` and `shortest_path` + reach the neighbor. The two rivals necessarily share a label, so the endpoint + is identified by which neighbor the answer reaches, not by name. + """ + return _load( + [ + _stub(), + _sourced(), + {"id": "alpha", "label": "AlphaService", "source_file": "app/Alpha.php", + "source_location": "L4", "community": 0}, + {"id": "beta", "label": "BetaService", "source_file": "app/Beta.php", + "source_location": "L4", "community": 0}, + ], + [ + {"source": SOURCED_ID, "target": "alpha", "context": "call"}, + {"source": "aaa_stub0", "target": "beta", "context": "call"}, + ], + ) + + +def test_shortest_path_anchors_on_the_sourced_node(): + """Anchored on the stub, this query was a false "No path found".""" + G = _repro_graph_with_neighbors() + out = _shortest_path_text(G, {"source": LABEL, "target": "AlphaService"}) + assert "No path" not in out and "No directed path" not in out + assert "FooRepository --related--> AlphaService" in out + + +def test_query_traverses_the_sourced_nodes_neighborhood(): + G = _repro_graph_with_neighbors() + out = _query_graph_text(G, LABEL) + assert "AlphaService" in out + assert "BetaService" not in out + + +# --- the per-term seed winner needs the rule too ---------------------------- + + +def _decorated_pair(): + """A stub whose label normalizes *differently* from its sourced rival's. + + `_pick_seeds` dedupes seeds by normalized label, so on the same-label repro + above the stub's per-term entry is dropped before it can be seeded and the + combined-ranking half of the fix carries the whole outcome. A decorated + declaration breaks that cover: `handle` and `handle()` are distinct dedup + keys, so the stub keeps its own seat and `best_seed_by_term`'s own tie-break + is the only thing deciding which node fills it (the #49 suite pins the same + pair for `resolve_seed`'s bare-name pass). + """ + return _load([ + {"id": "aaa_stub_handle", "label": "handle", "source_file": "", "community": 0}, + {"id": "zzz_app_svc_handle", "label": "handle()", + "source_file": "app/Svc.php", "source_location": "L9", "community": 0}, + ]) + + +def test_per_term_seed_winner_prefers_the_sourced_node(): + """Pins the `best_seed_by_term` half of the fix, which the same-label repro + cannot reach — goes red if the singleton tie-break key drops its sourced + preference.""" + G = _decorated_pair() + qs = _score_query(G, ["handle"], collect_per_term_seeds=True) + assert qs.best_seed_by_term == {"handle": "zzz_app_svc_handle"} + + +def test_query_ranks_the_sourced_node_first_for_a_decorated_declaration(): + """The same pair through the query pipeline. Both labels survive the seed + dedupe here, so both get seeded — what the rule decides is the order, and the + top seed is what `_pick_seeds`' coverage check and the seed-first rendering + both key off. + """ + G = _decorated_pair() + out = _query_graph_text(G, "handle") + assert "Start: ['handle()', 'handle']" in out + assert out.index("NODE handle() [src=app/Svc.php") < out.index("NODE handle [src=") + + +# --- the pre-existing sourced-vs-sourced tie is untouched ------------------- + + +def test_two_sourced_rivals_keep_their_existing_tie_break(): + """Sourced-vs-sourced (#2032's symbol case) still resolves by the id sort.""" + G = _load([ + {"id": "chat_port", "label": "MetricsPort", + "source_file": "services/chat/ports/metrics.port.ts", "community": 0}, + {"id": "scrape_port", "label": "MetricsPort", + "source_file": "services/scraping/ports/metrics.port.ts", "community": 0}, + ]) + assert _endpoint(G, "MetricsPort") == "chat_port" From 26f7b03be792d754a859c2ea87a5e9be8c0b3cd7 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Fri, 7 Aug 2026 13:50:17 -0300 Subject: [PATCH 39/41] fix(extract): lift the redundant _php_non_class_types receiver refusal (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_php_member_calls` refused every receiver whose declared type named a PHP `interface`, `enum` or `trait`, off the corpus-wide pre-scan marker. That was the right call while the three minted no definition node: the Laravel Contracts collision (`App\Contracts\Notifier` interface beside an unrelated `App\Support\Notifier` class) left exactly ONE definition under the short name, so the single-definition guard could not see the ambiguity and would have bound the stranger. their own methods attach to them. The collision therefore censuses TWO definitions and is refused by the single-definition guard unaided, while an interface named by exactly one declaration — or picked out of the namesakes by the calling file's own `use` — was still being refused, leaving recoverable member-call fan-in unrealized. Lift the refusal so those receivers resolve through the normal path and land on the declaration's own method node. Lifting it exposed a hole that the blanket refusal had been covering: `_php_pre_scan_class_namespaces` read `class_declaration` only, so an interface/enum/trait node never carried a declared FQN, and `PhpNameResolver`'s `use`-claim guard fell back to comparing the node's PSR-4 PATH — which `_php_fqn_names_another_class` treats as NO EVIDENCE whenever the path has fewer segments than the written name. A full build kept refusing (paths are still absolute at resolver time), but an incremental rebuild replays context nodes with relativized paths, so `use Illuminate\Contracts\Notifications\Notifier;` (4 segments) against `app/Contracts/Notifier.php` (3) bound the in-corpus interface the vendor import provably does not name: a wrong edge of the #16 class, in the mode `graphify update`/`watch` actually runs in. All four declaration kinds now feed `php_class_fqns` / `_php_class_fqns` / `fqn_def_nid`, which makes the guard whole-name and decisive on both build paths — and, as the same fix's recall half, lets a `use`-claimed in-corpus interface resolve to the declaration it names instead of dying on the two-candidate census. The persisted `_php_non_class_types` / `_php_interfaces` channel is kept whole — pre-scan, stamping, replay, both spellings read back — so older graphs still round-trip; only the refusal that consumed it goes. Comments and docstrings that asserted the pre-#47 reality are rewritten across extract.py, engine.py, watch.py, cli.py and php.py. Tests: interface-, enum- and trait-typed receivers bind their declaration's method, with a same-method-name decoy in every positive case; the vendor-import fixture is asserted on the FULL and INCREMENTAL build together (a full-build-only assertion is green on the broken code); the #11/#12 contract tests keep their intent — the same-short-named stranger is what may never be bound — and now assert full/incremental agreement rather than a refusal the guards make on their own. The legacy `_php_interfaces` spelling test pins the channel it actually covers now: `_php_context_interface_entry` recovering the names. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + graphify/cli.py | 18 +- graphify/extract.py | 116 +++--- graphify/extractors/engine.py | 123 +++--- graphify/extractors/php.py | 24 +- graphify/watch.py | 16 +- tests/test_php_ctor_body_receiver.py | 18 +- tests/test_php_member_calls.py | 563 +++++++++++++++++++++------ tests/test_watch.py | 121 ++++-- 9 files changed, 722 insertions(+), 281 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 230017882e..da6baf83dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: a PHP **function call never binds to a method** across files (`lawnstarter/graphify#52`). In PHP a bare `name(...)` — a `function_call_expression` — can only invoke a global or namespaced *function*; reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, each of which takes a different path through the extractor. The shared cross-file pass matched by normalized label, and that normalization (`raw.strip("()").lstrip(".")`) erases the member marker the engine writes — a method labeled `.event()` and a function labeled `event()` both key as `event` — so Laravel's `event(...)` helper bound to whichever class happened to declare an `event()` method. On the pinned 46,406-node api.lawnstarter.com corpus that is **848 fabricated inbound `calls` edges on `GetProviderBillingEventsTest::event()`**, a test method credited as the callee of 368 distinct source files, 308 of them not tests. It was not even an ambiguity the god-node tie-breakers could catch: the three `Event` *classes* key as `Event`, so the method was the **sole** exact-case candidate and bound through the single-candidate path at INFERRED 0.8, before any tie-break could apply. The refusal is language-semantic rather than heuristic — a method is simply not a candidate at a function-call site — so it is applied to the candidate **list**, not by re-keying the indexes, which covers every consumer at once: the single-candidate bind, the symbol/module import-evidence disambiguation, and the god-node tie-break. The same language-semantic argument extends past methods: a **class-like declaration is not invocable either** — `report($e)` where only `class Report` exists is a "Call to undefined function" fatal, not a constructor call — so class, interface, enum and trait nodes are refused at a function-call site alongside methods, leaving only plausible function targets. That half also fixes a pre-existing bind this release would otherwise have inherited: `foo(...)` matching a cross-file `class Foo` through the case-insensitive fold has always produced a `calls` edge into the class. Both index paths are covered, and the folded lookup is *retried* after the method/class refusal rather than skipped: the case-insensitive fallback only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise have hidden a real `function Event()` reachable solely under the folded key, and the site would have resolved to nothing at all. The retry has to carry the refusal with it, or it hands the folded index a candidate the exact-case path never offered — a capitalized class under the shadowed lowercase key, which is exactly the **7 `report(...)` → `class Checkr\Resources\Report` edges** measured on the rebuilt 46.6k-node corpus. The class discriminator is the `_callable_class` marker the `indirect_call` guard already reads, chosen for the same reason as the label one: it is replayed onto `resolution_context_nodes` by both `graphify update` and `watch`, so it still classifies unchanged-corpus candidates on an incremental rebuild. The call site is identified by a marker the PHP extractor stamps on the raw call, which is load-bearing rather than convenience: a `scoped_call_expression` is also not a member call, so refusing on "PHP and not a member call" alone would have silently widened the policy to `Class::method()`. The method-vs-function discriminator is the engine's own label convention (`.name()` for a member, `name()` for a top-level function), chosen because a label persists into `graph.json` and so still classifies the unchanged-corpus candidates an incremental rebuild hands back as resolution context (#2406) — an edge-derived test would not, since the shared pass never receives those edges. PHP-only by construction: a Ruby or Python bare call reaches a method through implicit self, so their candidate filtering is deliberately untouched, as is the member-call resolver (`_resolve_php_member_calls`). One residual is documented rather than chased: a `event(...)` call in the *same file* as a class declaring an `event()` method still binds to it, because the extractor resolves that raw call in-file and it never reaches the cross-file pass — unchanged behavior, and the reason the measured fan-in shrinks to near-zero rather than provably zero. Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to shed these edges, and re-extraction genuinely requires the release's version bump to roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`): entries are keyed by content hash *within* that namespace, so a same-version rebuild replays the pre-fix raw calls untouched. - Fix: `graphify query` and `graphify path` / `shortest_path` no longer resolve an endpoint to a **sourceless stub** when a real declaration carries the same label (`lawnstarter/graphify#54`, closing the gap `#49` left behind). `#49` taught `_find_node_tiers` and `affected`'s `resolve_seed` to drop stubs from a mixed exact tier, so `explain` and `affected` agree on the sourced declaration — but the scored path those two commands do not share, the `_score_nodes` ranking that feeds `_pick_scored_endpoint` (path) and `_pick_seeds` (query), never learned the rule. On the two-node repro — `FooRepository` at `source_file: ""` beside `FooRepository` at `app/bindings.php` — both nodes score **5619.081861425897**, identically, so the ranking fell through to its lexicographic node-id tie-break and answered with whichever id sorted first: `explain` named the declaration while `query` traversed the stub, reinstating for `explain`-vs-`query` exactly the divergence `#49` closed for `explain`-vs-`affected`, and violating the design intent the scorer's own comment states — that `path` and `query` resolve the same node `explain` does. The stub is rarely a merely cosmetic mismatch, because it is usually the *disconnected* node: `shortest_path` answered a reachable query with a false "No directed path found", and `query` spent its traversal on the stub's neighborhood instead of the declaration's. The fix is a **tie-break, not a scoring penalty**, and the distinction is load-bearing: a sourceless node can never *out*-score an otherwise-identical sourced one, since the only source-derived scoring term is additive and source hits do not count toward term coverage, so a shadowing stub always arrives at the sort as an exact tie and a tie-break is the complete and minimal intervention. The sourced preference is inserted directly under the score in both tie-break keys — the combined ranking sort and the per-term singleton-winner key that feeds the seed guarantee — above the label-length and node-id keys, which are arbitrary with respect to which node is real. No live score changes, so every score-sensitive baseline holds unedited. `#49`'s carve-out survives by construction rather than by a special case: an all-sourceless field has nothing to prefer, so a lone stub still wins its query and nothing that answered before now returns nothing. One asymmetry with `_find_node` is deliberate and left in place, since `_find_node` is unchanged here: it demotes stubs in its *exact* tier only, while a score tie can also arise from the prefix and substring tiers — on those shapes the scored path now prefers the sourced node where `_find_node` still answers by graph-iteration order, which is the more deterministic of the two answers, and closing the remaining gap would mean changing `_find_node` itself. Serve-side only: nothing in extraction, merge or the graph format changes, so an existing graph picks this up with **no rebuild and no re-extraction**. +- Fix: a PHP receiver typed with an `interface`, `enum` or `trait` name now binds to that declaration's own method instead of being refused outright (`lawnstarter/graphify#53`). `_resolve_php_member_calls` consulted a corpus-wide pre-scan of the non-class declaration names (`_php_non_class_types`) and skipped every such receiver — correct while the three minted no definition node, because the only thing such a receiver could land on was a same-short-named CLASS: Laravel's `App\Contracts\Notifier` interface beside an unrelated `App\Support\Notifier`, or `App\Enums\Status` beside an Eloquent `App\Models\Status`, each leaving exactly ONE definition under the short name and so satisfying the single-definition guard with the wrong answer. `#47` (0.9.38 above) gave all three kinds canonical sourced nodes that their own methods attach to, which makes the refusal redundant: the collision now presents TWO definitions and is refused by the guard unaided, while a `use`-imported name is decided by the declared-FQN match. What the refusal was still costing is the rest — an interface or enum named by exactly one declaration, and a `use`-claimed one whose import says which of the namesakes it means — so `$this->notifier->send()` on a `private Notifier $notifier` now lands on `Notifier::send()` itself. Implementations are still never guessed: a `MailNotifier implements Notifier` gets nothing from an interface-typed receiver, and the same-short-named stranger class gets nothing on any path. +- The lift required extending the declared-FQN pre-scan to the three kinds, and that half is a fix in its own right: `_php_pre_scan_class_namespaces` recorded `class_declaration` only, so an interface/enum/trait node NEVER carried a declared FQN — not on a full build, not through the `_php_class_fqns` marker (`#23`). `PhpNameResolver`'s `use`-claim guard then fell back to comparing the node's PSR-4 PATH, and `_php_fqn_names_another_class` treats a path with FEWER segments than the written name as *no evidence* and keeps the edge. On a full build the paths are still absolute at resolver time, so the comparison ran and refused; on an incremental rebuild the replayed context node carries the relativized `app/Contracts/Notifier.php` (3 segments), so `use Illuminate\Contracts\Notifications\Notifier;` (4) tripped the bail-out and bound the in-corpus interface that the vendor import provably does not name — a wrong edge of the `#16` class, appearing ONLY under `graphify update`/`watch`, which is the normal operating mode. `Illuminate\Contracts\\` is exactly 4 segments and Laravel apps routinely declare `App\Contracts\{Repository,Factory,Guard,Mailer,…}` beside imports of the framework contracts with those same short names, so the shape is ordinary rather than exotic. All four declaration kinds are now read into `php_class_fqns` / `_php_class_fqns` / `fqn_def_nid`, which makes the guard whole-name and decisive on both build paths and closes the full-vs-incremental parity hole; the `tests/test_php_member_calls.py` cases assert the two builds' verdicts TOGETHER, since a full-build-only assertion is green on the broken code. +- The `_php_non_class_types` / `_php_interfaces` channel is kept intact — pre-scan, per-file payload, file-node stamp, `graphify update`/`watch` replay, both spellings read back — so graphs written by earlier versions still round-trip. Nothing consumes it for resolution any more; it remains the only record of declaration KIND that survives into a graph. +- Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to pick this up: the new edges come from the receiver-typing pass and the declared FQNs come from a pre-scan, and AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild replays the pre-fix payloads untouched. ## 0.9.38 (unreleased) diff --git a/graphify/cli.py b/graphify/cli.py index fa9b5499c5..7d7f6b794e 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3215,14 +3215,16 @@ def _ctx_identity(source_file) -> str | None: "type": _node.get("type"), } # `_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. - # `_php_class_fqns` (#23) is the positive counterpart: - # the declared FQNs that let a claimed `use` import keep - # binding into an unchanged defining file (#22). + # marker channel as the callability flags: it records + # which of an unchanged PHP file's declarations are + # interfaces, enums or traits rather than classes. It + # drove a receiver refusal until #53 lifted it, and is + # still carried; `_php_interfaces` is the pre-#12 + # spelling, kept for older graphs. `_php_class_fqns` + # (#23) is what resolution reads today: the declared + # FQNs that let a claimed `use` import bind into an + # unchanged defining file (#22), and let the guard + # refuse a vendor import that only shares a short name. for _marker in ("_callable", "_callable_class", "_php_non_class_types", "_php_interfaces", "_php_class_fqns"): diff --git a/graphify/extract.py b/graphify/extract.py index f4db56d3b5..e1251f9f97 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3289,23 +3289,29 @@ def key(label: str) -> str: def _php_context_interface_entry(context_nodes: list[dict] | None) -> dict | None: """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. + 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. ``_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. + traits joined the set keeps handing back the names it does carry. + + The MEMBER-CALL RESOLVER no longer consults these names: #5/#12 used them to + refuse an interface/enum/trait-typed receiver outright, and #53 lifted that + refusal once #47 gave the three declarations canonical nodes of their own + (`_resolve_php_member_calls`). The channel itself — pre-scan, stamp, replay — + is kept whole: it is what a graph written by an older version still carries, + and it is the only record of which corpus names are non-class declarations + that survives a rebuild in which the declaring file is never dispatched. 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. + colliding context node (fresh wins) — taking the marker with it. """ names = sorted({ str(name) @@ -3365,6 +3371,22 @@ def _resolve_php_member_calls( (``$obj->method(...)``): the method is referenced, not invoked, so it resolves by exactly the rules above but is emitted as ``indirect_call`` (#15). + + A receiver typed with an ``interface``, ``enum`` or ``trait`` name resolves + by those same rules (#53). Until #47 the three minted no definition node, so + such a receiver could only ever land on a same-short-named CLASS — the + Laravel Contracts collision (``App\\Contracts\\Notifier`` vs + ``App\\Support\\Notifier``) or the enum-beside-model one — and #5/#12 + refused every one of them outright, off a corpus-wide pre-scan of the + declared names. Now that each declaration mints a canonical sourced node + that its own methods attach to, the same two rules that keep a CLASS-typed + receiver honest cover them: a calling file that names one through a ``use`` + import gets ``PhpNameResolver``'s verdict — the declared-FQN match when the + import names an in-corpus declaration, a refusal when it names a vendor type + that merely shares the short name — and a file that claims nothing falls to + the single-definition guard, which sees the collision as the two definitions + it now is. What the blanket refusal was costing is the rest: an interface or + enum named by exactly one declaration binds to that declaration's method. """ def key(label: str) -> str: # PHP class and method names are case-insensitive. @@ -3400,33 +3422,14 @@ 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`, `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. - # - # `per_file` aligns 1:1 with the files dispatched THIS run, so an incremental - # rebuild that leaves the declaring file untouched used to see no such 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 one extra entry, so this single channel still covers the whole - # corpus — see `_php_context_interface_entry`. - non_class_type_names = { - key(name) - for result in per_file - for keyname in ("php_non_class_types", "php_interfaces") - for name in result.get(keyname, []) - } - - # Fully qualified class names as each defining file DECLARES them (#14), so + # Fully qualified type 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. + # agrees with. Keyed by defining file, then by short name. Covers every + # namespaced declaration, `interface`/`enum`/`trait` included since they + # became bindable receiver types (#53) — a declaration without a declared + # FQN is one the guards can only judge by its path, and a replayed context + # node's path is short enough to make that judgement abstain. class_fqn_by_file: dict[str, dict[str, str]] = {} for result in per_file: declared = result.get("php_class_fqns") @@ -3438,7 +3441,7 @@ def key(label: str) -> str: # call site in `_resolve_csharp_member_calls`. A name the calling file # CLAIMS through a `use` import or writes out qualified is decided here and # never falls back: the refusal is the whole fix for #16, and the claimed - # FQN binding to the class whose file declares exactly that name is the + # FQN binding to the type whose file declares exactly that name is the # recall counterpart (#22) — see PhpNameResolver. resolver = PhpNameResolver(all_nodes, all_edges, type_def_nids, class_fqn_by_file) @@ -3482,11 +3485,6 @@ def declared_fqn(type_node: dict | None) -> str | None: type_name = raw_call.get("receiver_type") if not type_name: continue # untyped / union-typed / unknown receiver: refuse - if key(type_name) in non_class_type_names: - # An interface names no implementation, a trait is not a - # type, and an enum's methods live on no definition node: - # refuse rather than bind a same-short-named stranger. - continue resolved, decisive = resolver.resolve_type_name( type_name, raw_call.get("receiver_type_qualified"), @@ -3523,8 +3521,8 @@ def declared_fqn(type_node: dict | None) -> str | None: "target": method_nid, # A first-class callable NAMES the method without invoking it, so it # is the repo's `indirect_call`, not `calls` (#15). Everything else - # above — receiver typing, the single-definition and interface/enum/ - # trait refusals, the confidence ladder — is deliberately shared. + # above — receiver typing, the claimed-name and single-definition + # refusals, the confidence ladder — is deliberately shared. "relation": "indirect_call" if raw_call.get("fcc") else "calls", "context": "call", "confidence": "EXTRACTED" if exact else "INFERRED", @@ -5267,10 +5265,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 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 + unchanged callee. They also carry the unchanged corpus's PHP + interface, enum and trait names, stamped as `_php_non_class_types` + on each PHP file node (#11, #12) — a persisted channel kept whole + for older graphs, though the member-call resolver stopped consuming + it when #53 lifted the refusal it fed. 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 @@ -6350,12 +6349,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/#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. + # #11/#12: the unchanged corpus's INTERFACE, ENUM and TRAIT names ride in on + # the context nodes' `_php_non_class_types` marker and are handed over as one + # extra `per_file` entry (scratch list, the real `per_file` is untouched). + # The member-call resolver stopped consuming them in #53 — its refusal was + # made redundant by the declaration nodes #47 mints — but the channel is kept + # whole for the graphs still carrying it; see `_php_context_interface_entry`. if resolution_context_nodes or resolution_context_edges: _rl_nodes = list(resolution_nodes) _rl_edges = all_edges + list(resolution_context_edges or []) @@ -6510,11 +6509,12 @@ 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_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. + # `_php_non_class_types` (#11, #12) persists for the same reason: it is the + # only record of which corpus names are interface/enum/trait declarations + # that survives a rebuild leaving the declaring file undispatched. Both + # spellings are read back, the pre-#12 `_php_interfaces` included. Nothing + # consumes it for resolution since #53 lifted the receiver refusal it fed; + # the channel stays because the graphs written by earlier versions carry it. # `_php_class_fqns` (#23) persists likewise, failing in the safe direction # too: a pre-marker graph loses the declared-FQN BINDING (#22) on an # incremental rebuild — an absent edge, never a guessed one — until the diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index bc229226bc..19f7eb82ee 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -144,12 +144,13 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: stack.extend(n.children) return out -# PHP declaration kinds whose names the member-call resolver refuses to bind a -# receiver to (#1682). All three ARE in `_PHP_CONFIG.class_types` and do mint -# definition nodes as of #47, so this is no longer "the kinds outside -# class_types" — it is the refusal policy, stated for its own sake. The name is -# historical and kept because the marker it feeds (`_php_non_class_types`) is -# persisted in graph.json and read back on incremental rebuilds (#11/#12). +# The PHP declaration kinds that are not `class`. All three ARE in +# `_PHP_CONFIG.class_types` and mint definition nodes as of #47, so this is no +# longer "the kinds outside class_types"; it was the member-call resolver's +# refusal policy (#1682) until #53 lifted that refusal, and what remains is a +# record of declaration KIND. The name and the marker it feeds +# (`_php_non_class_types`) are kept as spelled: the marker is persisted in +# graph.json and read back on incremental rebuilds (#11/#12). _PHP_NON_CLASS_DECLARATIONS = frozenset({ "interface_declaration", "enum_declaration", @@ -160,21 +161,21 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: 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). - The member-call resolver refuses to bind a receiver typed with one of these - names. Laravel's conventions make the collision that motivates the refusal - 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` — used to leave exactly ONE definition under - that short name, which satisfied the single-definition guard and bound the - receiver to a total stranger. The names are threaded to the resolver so it - can refuse instead. - - Since #47 all three kinds mint definition nodes, so that same collision now - presents TWO definitions and the single-definition guard would refuse on its - own. The pre-scan is kept anyway: it still refuses the case the guard cannot - see — a lone interface with no same-named class, which would otherwise start - binding — and that is a behavior change #47 deliberately did not make. - Whether to lift the refusal now that the nodes exist is a separate decision. + These names once drove a REFUSAL: the member-call resolver would not bind a + receiver typed with one of them. Laravel's conventions make the collision + that motivated it 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` — because while the three minted no + definition node, exactly ONE definition existed under the short name, which + satisfied the single-definition guard and bound the receiver to a stranger. + + #47 gave all three kinds definition nodes, so the collision now presents TWO + and the guard refuses on its own; #53 lifted the blanket refusal on that + basis, and nothing consumes these names for resolution any more (see + `_php_context_interface_entry` in extract.py). The pre-scan and its + `_php_non_class_types` marker are kept because they are persisted in + graph.json: they are the only record of declaration KIND that survives into + a graph, and older graphs carry them. """ out: set[str] = set() stack = [root_node] @@ -190,8 +191,22 @@ def _php_pre_scan_non_class_declarations(root_node, source: bytes) -> set[str]: return out +# The declaration kinds `_php_pre_scan_class_namespaces` reads a declared FQN +# off. `class_declaration` alone until #53: once an interface/enum/trait-typed +# receiver became bindable, a declaration WITHOUT a declared FQN was one the +# name guard could only judge by its PSR-4 path — strictly weaker, and weakest +# on exactly the incremental path where paths arrive relativized. All four kinds +# declare a namespaced type, so all four are read. +_PHP_FQN_DECLARATIONS = frozenset({ + "class_declaration", + "interface_declaration", + "enum_declaration", + "trait_declaration", +}) + + 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). + """Map every namespaced type 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 @@ -201,9 +216,23 @@ def _php_pre_scan_class_namespaces(root_node, source: bytes) -> dict[str, str]: files, generated code), and the written name then corroborates a class that exists nowhere. The declaration is right there in the source — read it. + ``interface``, ``enum`` and ``trait`` declarations are read alongside + ``class`` (#53). They were left out while #5/#12 refused every receiver + typed with such a name, which made their FQNs unused; once the refusal was + lifted and those declarations became bindable, the omission left + ``PhpNameResolver`` judging them by PSR-4 path alone — and + ``_php_fqn_names_another_class`` treats a path with fewer segments than the + written name as NO EVIDENCE and keeps the edge. A rebuild replays context + nodes with RELATIVIZED paths, so ``use Illuminate\\Contracts\\…\\Notifier;`` + (4 segments) against a replayed ``app/Contracts/Notifier.php`` (3) bound the + in-corpus interface that the vendor import provably does not name — a wrong + edge of the #16 class, and only on the incremental path. A declared FQN + makes that comparison whole-name and decisive on both paths, and lets + ``fqn_def_nid`` (#22) pick the imported one of several namesakes. + 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 + ``namespace X { … }`` applies to its block. A type 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 @@ -213,9 +242,9 @@ def _php_pre_scan_class_namespaces(root_node, source: bytes) -> 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. + # scopes stay right without walking siblings in order. A declaration body is + # never descended into: PHP has no nested type declarations, and an + # anonymous class inside a method names nothing. stack = [(root_node, "")] while stack: node, namespace = stack.pop() @@ -232,7 +261,7 @@ def _php_pre_scan_class_namespaces(root_node, source: bytes) -> dict[str, str]: else: current = declared # applies to the declarations that follow continue - if child.type == "class_declaration": + if child.type in _PHP_FQN_DECLARATIONS: 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: @@ -5751,32 +5780,36 @@ def _scan_js_module_dispatch(n) -> None: if swift_extensions: result["swift_extensions"] = swift_extensions 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. + # Which names this file declares as `interface`/`enum`/`trait` rather + # than `class` (#1682). Sorted for a stable AST-cache payload. The + # member-call resolver consumed this to refuse such receivers until #53 + # lifted the refusal; what is left is a persisted record of declaration + # KIND, which nothing else in a graph carries. result["php_non_class_types"] = sorted(php_non_class_type_names) # The per-file payload above only reaches the resolver for files # dispatched THIS run, so on an incremental rebuild an unchanged - # declaring 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 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. `_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. + # declaring file used to drop out of the refusal entirely 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 the marker + # describes the file's declarations as a set; 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. + # `_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 still hands back what it does name. for n in nodes: if n["id"] == file_nid: 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 - # 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. + # The `namespace` this file declares for each type it defines — classes + # plus, since #53, interfaces/enums/traits — so the inline-`new` + # corroboration and the `use`-claim guard 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} # Like `_php_non_class_types` above, the payload only reaches the # resolver for files dispatched THIS run — but the declared-FQN diff --git a/graphify/extractors/php.py b/graphify/extractors/php.py index 5fcc1e8ece..74281ea526 100644 --- a/graphify/extractors/php.py +++ b/graphify/extractors/php.py @@ -101,6 +101,13 @@ def _php_fqn_names_another_class( from a different class. Refusing there would delete true edges on incremental rebuilds only, where the declaration is what is missing; persisting it for unchanged files is #23. + + That second branch is why every bindable declaration KIND has to reach the + declared-FQN map: an `interface`/`enum`/`trait` left out of it was judged by + path alone, and a replayed context node's relativized path has fewer + segments than a 4-segment vendor FQN — so `use Illuminate\\Contracts\\…\\ + Notifier;` read as "no evidence" and kept an edge onto the in-corpus + `App\\Contracts\\Notifier` it provably does not name (#53). """ if type_node is None: return False @@ -138,14 +145,17 @@ class PhpNameResolver: The refusal (#21) is strictly subtractive; the declared-FQN index layered on top (#22) is its additive counterpart. When the claimed FQN matches the name - some in-corpus class's own file DECLARES (#14), that match outranks the + some in-corpus type's own file DECLARES (#14), that match outranks the short-name census: it picks the imported one of several same-short-named - classes, and follows a renaming alias (``use App\\X as Y;``) to a class the - written short name never would have found. The index only knows classes - whose declared FQNs are available — for a file left undispatched by an - incremental rebuild that is the persisted ``_php_class_fqns`` marker (#23); - a graph written before that marker simply yields no match and the verdict - falls back to the #21 rules, adding no edge rather than a wrong one. + types, and follows a renaming alias (``use App\\X as Y;``) to a type the + written short name never would have found. The index only knows types whose + declared FQNs are available — for a file left undispatched by an incremental + rebuild that is the persisted ``_php_class_fqns`` marker (#23); a graph + written before that marker simply yields no match and the verdict falls back + to the #21 rules, adding no edge rather than a wrong one. ``interface``, + ``enum`` and ``trait`` declarations are in the index since #53 made them + bindable receiver types; without their FQNs the #21 rules judged them by + PSR-4 path alone, which is weaker and, on replayed context nodes, wrong. """ def __init__( diff --git a/graphify/watch.py b/graphify/watch.py index 2a257c75e1..fbd82fc552 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1286,14 +1286,16 @@ def _add_deleted_source(path: Path) -> None: # thing that lets an unchanged target pass the # indirect_call guard — never re-derived from the label. # `_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 + # it is the only record of which of an unchanged PHP file's + # declarations are interfaces, enums or traits rather than + # classes. It drove a receiver refusal until #53 lifted it; + # it is still carried, and `_php_interfaces` — that marker's + # pre-#12 spelling — with it, so a graph.json written before # enums and traits joined the set still round-trips. - # `_php_class_fqns` (#23) is the positive counterpart: the - # declared FQNs that let a claimed `use` import keep binding - # into an unchanged defining file (#22). + # `_php_class_fqns` (#23) is what resolution reads today: + # the declared FQNs that let a claimed `use` import bind + # into an unchanged defining file (#22) and let the guard + # refuse a vendor import that merely shares a short name. for marker in ("_callable", "_callable_class", "_php_non_class_types", "_php_interfaces", "_php_class_fqns"): diff --git a/tests/test_php_ctor_body_receiver.py b/tests/test_php_ctor_body_receiver.py index d2802c234e..b6416bcb52 100644 --- a/tests/test_php_ctor_body_receiver.py +++ b/tests/test_php_ctor_body_receiver.py @@ -622,9 +622,16 @@ def test_assignment_in_a_non_constructor_method_emits_no_edge(tmp_path: Path): assert _no_search_edge(calls, index) -def test_interface_typed_param_assigned_to_a_property_emits_no_edge(tmp_path: Path): - """The interface/enum/trait refusal (#5, #12) applies to a ctor-body-bound - receiver too: an interface names no implementation.""" +def test_interface_typed_param_assigned_to_a_property_binds_the_interface(tmp_path: Path): + """A ctor-body-bound receiver typed with an INTERFACE binds to the + interface's own method (#53), not to a same-short-named implementation. + + #5/#12 refused this outright, back when an interface minted no definition + node and the only thing a receiver could land on was a stranger class. Post + #47/#53 the contract has its own `search()` node and the `use` names it + unambiguously — `App\\Services\\LeadHunterService` is a DIFFERENT type that + the controller never imported, and it stays unbound, which is the whole + point the original refusal was protecting.""" calls, r = _calls(tmp_path, { "app/Contracts/LeadHunterService.php": ( " bool: return any(src == caller and "notify" in tgt.lower() for src, tgt in calls) +def test_unique_interface_typed_property_binds_to_the_interface_method(tmp_path: Path): + """#53 criterion 1: `private Notifier $n` + `$this->n->send()` where the + corpus holds exactly ONE `Notifier` — the interface — binds to the + interface's own `send()` declaration node (post-#47 it has one).""" + calls, r = _calls(tmp_path, { + **_UNIQUE_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->send('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + contract_send = _find(r, ".send()", "contracts_notifier") + assert (go, contract_send) in calls, \ + "an unambiguous interface-typed receiver names the interface's method" + assert (go, _find(r, ".send()", "auditlog")) not in calls, \ + "the same method name on an unrelated class is not the receiver's type" + edge = calls[(go, contract_send)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + assert edge["context"] == "call" + + +def test_unique_interface_binds_through_the_single_definition_guard(tmp_path: Path): + """The same binding with NO `use` import to claim the name, so + `PhpNameResolver` abstains and the corpus-wide short-name census decides — + the plain single-definition guard path. Both files sit in the global + namespace, which is what makes the unqualified annotation name the + interface.""" + calls, r = _calls(tmp_path, { + "app/Contracts/Notifier.php": ( + "notifier->send('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".send()", "contracts_notifier")) in calls + assert (go, _find(r, ".send()", "auditlog")) not in calls + + +def test_interface_typed_param_binds_to_the_interface_method(tmp_path: Path): + """The typed-parameter receiver path (#4) reaches the interface too.""" + calls, r = _calls(tmp_path, { + **_UNIQUE_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "send('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".send()", "contracts_notifier")) in calls + assert (go, _find(r, ".send()", "auditlog")) not in calls + + +def test_colliding_interface_and_class_short_name_emits_no_edge(tmp_path: Path): + """#53 criterion 3, on the guard the lifted refusal hands the job to: with + `App\\Contracts\\Notifier` (interface) and `App\\Support\\Notifier` (class) + both in the corpus the short name censuses TWO definitions, so the + single-definition guard refuses on its own. No `use` import here — the + caller shares the interface's namespace, which is what makes the bare + annotation name it — so `PhpNameResolver` abstains and the guard is the only + thing standing between the receiver and the stranger. PHP itself would bind + the interface; refusing is a recall gap, never a wrong edge.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Contracts/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_typed_property_does_not_guess_implementation(tmp_path: Path): + """The contract is the receiver's type; its implementations are not. + + `MailNotifier implements Notifier` is the only class that could satisfy the + annotation at runtime, and it still gets NOTHING — an interface names a + contract, and picking one implementation out of the corpus is the guess #5 + forbade and #53 does not reintroduce. What the receiver does bind is the + interface's OWN `notify()` node (#47), which is where the fan-in belongs.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Http/Dispatcher.php": ( @@ -1193,13 +1329,20 @@ def test_interface_typed_property_does_not_guess_implementation(tmp_path: Path): 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) + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls -def test_interface_short_name_collision_emits_no_edge(tmp_path: Path): +def test_interface_short_name_collision_binds_the_imported_one(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.""" + (unrelated class) share a short name, and the calling file's `use` says + WHICH one it means. + + #5 could only refuse here: the interface minted no node, so the short-name + census saw the stranger alone and would have bound it. Post-#47/#53 both + are definitions, and the declared-FQN index (#22, extended to non-class + declarations for #53) matches the imported FQN against the name the + interface's own file declares — decisively, without ever consulting the + census. The stranger is still what must not be bound.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Http/Dispatcher.php": ( @@ -1216,11 +1359,13 @@ def test_interface_short_name_collision_emits_no_edge(tmp_path: Path): 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) + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls -def test_interface_refusal_is_case_insensitive(tmp_path: Path): - """PHP type names are case-insensitive: `notifier` IS `Notifier`.""" +def test_interface_binding_is_case_insensitive(tmp_path: Path): + """PHP type names are case-insensitive: `notifier` IS `Notifier`, so the + lowercase annotation reaches the same interface and still never the + same-short-named class.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Http/Dispatcher.php": ( @@ -1235,11 +1380,12 @@ def test_interface_refusal_is_case_insensitive(tmp_path: Path): }) go = _find(r, ".go()", "dispatcher") - assert not _notified(calls, go) + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls + assert (go, _find(r, ".notify()", "support_notifier")) not in calls -def test_interface_typed_param_emits_no_edge(tmp_path: Path): - """The typed-parameter receiver path (#4) refuses interfaces too.""" +def test_interface_typed_param_binds_the_imported_interface(tmp_path: Path): + """The typed-parameter receiver path (#4) reaches the interface too.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Http/Dispatcher.php": ( @@ -1253,13 +1399,19 @@ def test_interface_typed_param_emits_no_edge(tmp_path: Path): }) go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls assert (go, _find(r, ".notify()", "support_notifier")) not in calls - assert not _notified(calls, go) + assert (go, _find(r, ".notify()", "mailnotifier")) not in calls 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.""" + """The inline-new receiver path (#3) emits nothing here, and for the reason + that survives the lift: this file imports nothing, and an inline `new` + carries its written namespace on `receiver_qualified` — which only feeds the + EXTRACTED promotion, not `resolve_type_name`. So the resolver abstains, the + short name censuses TWO `Notifier` definitions, and the single-definition + guard refuses. `new` on an interface is invalid PHP anyway; the invariant + worth pinning is that the same-short-named stranger is never what it binds.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Http/Dispatcher.php": ( @@ -1278,8 +1430,11 @@ def test_interface_inline_new_emits_no_edge(tmp_path: Path): 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.""" +def test_interface_typed_local_new_binds_the_imported_interface(tmp_path: Path): + """The typed-local receiver path (#4): `$n = new Notifier()` is broken PHP + for an interface, but the local's declared type is still the name the file + imported — so it binds the interface it NAMES, never the stranger the short + name would otherwise census.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Http/Dispatcher.php": ( @@ -1297,12 +1452,12 @@ def test_interface_typed_local_new_emits_no_edge(tmp_path: Path): go = _find(r, ".go()", "dispatcher") assert (go, _find(r, ".notify()", "support_notifier")) not in calls - assert not _notified(calls, go) + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls 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.""" + """Name scoping: a CLASS-typed receiver resolves to its class, and the + same-named interface elsewhere in the corpus changes nothing.""" calls, r = _calls(tmp_path, { **_IFACE_CORPUS, "app/Audit/AuditTrail.php": ( @@ -1326,15 +1481,15 @@ def test_class_receiver_still_resolves_when_an_interface_exists(tmp_path: Path): assert (go, _find(r, ".notify()", "support_notifier")) not in calls -# ── Enum- and trait-typed receivers are refused (#12) ──────────────────────── +# ── Enum- and trait-typed receivers (#12, #53) ─────────────────────────────── # -# `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). +# Pre-#47 `enum_declaration` and `trait_declaration` minted no definition node +# either, so they leaked exactly like interfaces did before #5: `App\Enums\Status` +# (enum) beside an unrelated `App\Legacy\Status` (class) left ONE definition under +# that short name, and the single-definition guard bound the stranger. The +# Laravel shape is an enum mirroring a model. Post-#47/#53 the collision censuses +# two definitions and is refused on that basis, while an enum's own methods ARE +# call targets — the recall gap #12 documented is closed below. _ENUM_CORPUS = { "app/Enums/Status.php": ( @@ -1365,9 +1520,11 @@ def _runner(body: str) -> str: ) -def test_enum_typed_property_emits_no_edge(tmp_path: Path): - """`private Status $status;` where Status is an enum: the same-short-named - `App\\Legacy\\Status` class is a total stranger, never the receiver.""" +def test_enum_typed_property_binds_the_imported_enum(tmp_path: Path): + """`private Status $status;` where Status is the imported enum: the + same-short-named `App\\Legacy\\Status` class is a total stranger and never + the receiver, while the enum's own `label()` (#47) is exactly what the + annotation names.""" calls, r = _calls(tmp_path, { **_ENUM_CORPUS, "app/Runner.php": _runner( @@ -1378,10 +1535,10 @@ def test_enum_typed_property_emits_no_edge(tmp_path: Path): go = _find(r, ".go()", "runner") assert (go, _find(r, ".label()", "legacy_status")) not in calls - assert not _labelled(calls, go) + assert (go, _find(r, ".label()", "enums_status")) in calls -def test_enum_promoted_ctor_param_emits_no_edge(tmp_path: Path): +def test_enum_promoted_ctor_param_binds_the_imported_enum(tmp_path: Path): calls, r = _calls(tmp_path, { **_ENUM_CORPUS, "app/Runner.php": _runner( @@ -1392,10 +1549,10 @@ def test_enum_promoted_ctor_param_emits_no_edge(tmp_path: Path): go = _find(r, ".go()", "runner") assert (go, _find(r, ".label()", "legacy_status")) not in calls - assert not _labelled(calls, go) + assert (go, _find(r, ".label()", "enums_status")) in calls -def test_enum_typed_param_emits_no_edge(tmp_path: Path): +def test_enum_typed_param_binds_the_imported_enum(tmp_path: Path): calls, r = _calls(tmp_path, { **_ENUM_CORPUS, "app/Runner.php": _runner( @@ -1405,12 +1562,13 @@ def test_enum_typed_param_emits_no_edge(tmp_path: Path): go = _find(r, ".go()", "runner") assert (go, _find(r, ".label()", "legacy_status")) not in calls - assert not _labelled(calls, go) + assert (go, _find(r, ".label()", "enums_status")) in calls -def test_enum_fqn_typed_property_emits_no_edge(tmp_path: Path): +def test_enum_fqn_typed_property_binds_the_written_enum(tmp_path: Path): """The sharpest form: the source names `\\App\\Enums\\Status` outright, so - binding `App\\Legacy\\Status` contradicts the written type.""" + binding `App\\Legacy\\Status` would contradict the written type — and the + written type is precisely what the declared-FQN index now matches.""" calls, r = _calls(tmp_path, { **_ENUM_CORPUS, "app/Runner.php": _runner( @@ -1421,11 +1579,13 @@ def test_enum_fqn_typed_property_emits_no_edge(tmp_path: Path): go = _find(r, ".go()", "runner") assert (go, _find(r, ".label()", "legacy_status")) not in calls - assert not _labelled(calls, go) + assert (go, _find(r, ".label()", "enums_status")) in calls -def test_enum_typed_local_new_emits_no_edge(tmp_path: Path): - """The typed-local receiver path (#4) refuses enums too.""" +def test_enum_typed_local_new_binds_the_imported_enum(tmp_path: Path): + """The typed-local receiver path (#4): `new Status()` is broken PHP for an + enum, but the local's declared type still names the imported enum and never + the stranger.""" calls, r = _calls(tmp_path, { **_ENUM_CORPUS, "app/Runner.php": _runner( @@ -1438,12 +1598,17 @@ def test_enum_typed_local_new_emits_no_edge(tmp_path: Path): go = _find(r, ".go()", "runner") assert (go, _find(r, ".label()", "legacy_status")) not in calls - assert not _labelled(calls, go) + assert (go, _find(r, ".label()", "enums_status")) in calls -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.""" +def test_enum_inline_new_binds_the_written_enum(tmp_path: Path): + """The inline-new receiver path (#3) contrasted with the interface one + above: `new` on an enum is equally invalid PHP, but here the runner DOES + `use App\\Enums\\Status`, so the claim is decided by the declared-FQN index + rather than left to the two-candidate census. The written + `\\App\\Enums\\Status` then corroborates the enum's declared name, which is + what promotes the edge to EXTRACTED. The stranger stays unbound either + way — that is the invariant #12 was protecting.""" calls, r = _calls(tmp_path, { **_ENUM_CORPUS, "app/Runner.php": _runner( @@ -1454,11 +1619,13 @@ def test_enum_inline_new_emits_no_edge(tmp_path: Path): }) go = _find(r, ".go()", "runner") + enum_label = _find(r, ".label()", "enums_status") assert (go, _find(r, ".label()", "legacy_status")) not in calls - assert not _labelled(calls, go) + assert (go, enum_label) in calls + assert calls[(go, enum_label)]["confidence"] == "EXTRACTED" -def test_enum_refusal_is_case_insensitive(tmp_path: Path): +def test_enum_binding_is_case_insensitive(tmp_path: Path): """PHP type names are case-insensitive: `status` IS `Status`.""" calls, r = _calls(tmp_path, { **_ENUM_CORPUS, @@ -1469,15 +1636,22 @@ def test_enum_refusal_is_case_insensitive(tmp_path: Path): }) go = _find(r, ".go()", "runner") - assert not _labelled(calls, go) + assert (go, _find(r, ".label()", "enums_status")) in calls + assert (go, _find(r, ".label()", "legacy_status")) not in calls -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.""" +def test_enum_without_a_colliding_class_binds_to_the_enum_method(tmp_path: Path): + """#53 criterion 2: drop the colliding class and `Status` names exactly one + type — the enum — whose `label()` hangs off its own declaration node + (#47). The receiver binds there. This is the recall gap #12 documented, + now closed; the decoy class proves it is the declared type doing the work + and not a bare method-name match.""" calls, r = _calls(tmp_path, { "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "status->label(); }" @@ -1485,12 +1659,21 @@ def test_enum_without_a_colliding_class_emits_no_edge(tmp_path: Path): }) go = _find(r, ".go()", "runner") - assert not _labelled(calls, go) + enum_label = _find(r, ".label()", "enums_status") + assert (go, enum_label) in calls, \ + "an unambiguous enum-typed receiver names the enum's own method" + assert (go, _find(r, ".label()", "models_lead")) not in calls + edge = calls[(go, enum_label)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 -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.""" +def test_trait_typed_receiver_binds_the_imported_trait(tmp_path: Path): + """A trait is not a type, so a trait-typed receiver is already broken PHP. + The graph follows the name the source actually writes — the imported + `App\\Support\\Cache` trait, whose `flush()` is a real node post-#47 — and + never the same-short-named `App\\Legacy\\Cache` class, which is what the + #12 refusal existed to prevent.""" calls, r = _calls(tmp_path, { "app/Support/Cache.php": ( " bool: + return any(src == caller and "send" in tgt.lower() for src, tgt in calls) + + +def test_vendor_import_shadowing_an_interface_refuses_on_both_builds(tmp_path: Path): + """A `use` of a same-short-named type from OUTSIDE the corpus must refuse — + on the incremental path as well as the full one (#16, #53). + + `use Illuminate\\Contracts\\Notifications\\Notifier;` CLAIMS the short name + for a vendor interface the corpus does not contain, so the in-corpus + `App\\Contracts\\Notifier` is a different type and must get no edge. That + verdict is `PhpNameResolver`'s (#21), and it is only decisive when the + declaration carries a declared FQN: without one the guard falls back to + comparing the node's PSR-4 PATH, and a replayed context node's path is + RELATIVIZED — fewer segments than the vendor FQN has — which trips the + "not enough evidence" bail-out and binds. The full build never sees that + (its paths are still absolute at resolver time), so full and incremental + must be asserted TOGETHER or the hole hides in the mode `graphify update` + actually runs in.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Contracts/Notifier.php": ( + "notifier->send('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + assert not _sent(full_calls, go), "full-build baseline must refuse the claim" + assert not _sent(inc_calls, go), \ + "a vendor `use` must not bind the in-corpus interface on a rebuild" + + +def test_vendor_import_shadowing_an_enum_refuses_on_both_builds(tmp_path: Path): + """The enum shape of the test above, same channel and same verdict.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + _INCR_RUNNER: ( + "status->label(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + assert not _labelled(full_calls, go), "full-build baseline must refuse the claim" + assert not _labelled(inc_calls, go), \ + "a vendor `use` must not bind the in-corpus enum on a rebuild" + + +def test_unique_interface_binding_survives_incremental_rebuild(tmp_path: Path): + """#53 criterion 4: the interface's file is unchanged and therefore NOT + dispatched — its declaration node and its `method` edge reach the resolver + only through the #2437 replay channel. The binding must be the same one the + full build makes, or every interface edge would evaporate on the first + `graphify update`.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_UNIQUE_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->send('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + contract_send = _find(full, ".send()", "contracts_notifier") + assert (go, contract_send) in full_calls, "full-build baseline must bind" + assert (go, contract_send) in inc_calls, \ + "an undispatched interface file must keep its replayed binding" + assert (go, _find(full, ".send()", "auditlog")) not in inc_calls + + +# ENUM and TRAIT declarations replay over the same channel and get the same +# parity treatment (#12). Before #47 an unchanged `App\Enums\Status` file left +# `App\Legacy\Status` as the one visible definition on a rebuild — the wrong edge +# #12 had closed on the full-build path, coming straight back on the incremental +# one. Now it is the declared FQN that has to survive the replay for the two +# builds to agree; the assertions below pin both ends of that. _INCR_RUNNER = "app/Http/Runner.php" @@ -1748,9 +2032,11 @@ def _incr_enum_corpus(body: str) -> dict[str, str]: } -def test_enum_refusal_survives_incremental_rebuild(tmp_path: Path): - """The enum's file is unchanged and therefore NOT dispatched: its name must - still reach the resolver, so the rebuild agrees with the full build.""" +def test_enum_binding_agrees_across_an_incremental_rebuild(tmp_path: Path): + """The enum's file is unchanged and therefore NOT dispatched: its node, its + `method` edge and its declared FQN must all still reach the resolver, so the + rebuild agrees with the full build — and the same-short-named + `App\\Legacy\\Status` gets nothing on either.""" (full_calls, full), (inc_calls, inc) = _full_then_incremental( tmp_path, _incr_enum_corpus( @@ -1762,27 +2048,34 @@ def test_enum_refusal_survives_incremental_rebuild(tmp_path: Path): go = _find(inc, ".go()", "runner") stranger = _find(full, ".label()", "legacy_status") - assert not _labelled(full_calls, go), "full-build baseline must refuse" + enum_label = _find(full, ".label()", "enums_status") + assert (go, enum_label) in full_calls, "full-build baseline binds the enum" + assert (go, enum_label) in inc_calls, \ + "an undispatched enum file must keep its replayed binding" 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) + "a rebuild must not hand the edge to App\\Legacy\\Status" -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( +def test_enum_typed_param_binding_agrees_across_an_incremental_rebuild(tmp_path: Path): + """The typed-parameter entry point keeps full/incremental parity too.""" + (full_calls, 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") + enum_label = _find(full, ".label()", "enums_status") + assert (go, enum_label) in full_calls + assert (go, enum_label) in inc_calls 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, { +def test_trait_binding_agrees_across_an_incremental_rebuild(tmp_path: Path): + """A trait-typed receiver is broken PHP, but its verdict must not depend on + which files a rebuild happened to dispatch: the imported trait on both + builds, the same-short-named `App\\Legacy\\Cache` class on neither.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { "app/Support/Cache.php": ( "status->label(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + enum_label = _find(full, ".label()", "enums_status") + assert (go, enum_label) in full_calls, "full-build baseline must bind" + assert (go, enum_label) in inc_calls, \ + "an undispatched enum file must keep its replayed binding" + assert (go, _find(full, ".label()", "models_lead")) 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.""" + under `_php_interfaces`, and both spellings must keep being read back off + the resolution context — the rename must not silently drop a channel that + older graphs are still using. + + #53 lifted the RECEIVER REFUSAL these names used to feed, so what is pinned + here is the channel itself: `_php_context_interface_entry` recovering the + unchanged corpus's declared names under either spelling. Resolution now runs + off a different marker on the same nodes — `_php_class_fqns` (#23) — which + is why the downgraded context still binds the imported contract and still + leaves `App\\Support\\Notifier` alone.""" (_, full), _ = _full_then_incremental(tmp_path, { **_IFACE_CORPUS, _INCR_DISPATCHER: ( @@ -1865,6 +2197,9 @@ def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): node["_php_interfaces"] = names # the pre-#12 spelling downgraded += 1 assert downgraded == 1, "exactly the interface's file node carries the names" + assert _php_context_interface_entry(ctx_nodes) == { + "php_non_class_types": ["Notifier"] + }, "the pre-#12 spelling must still be recovered off the context nodes" caller = tmp_path / "corpus" / _INCR_DISPATCHER inc = extract([caller], cache_root=tmp_path / "corpus", @@ -1877,7 +2212,7 @@ def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): go = _find(inc, ".go()", "dispatcher") assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls - assert not _notified(inc_calls, go) + assert (go, _find(full, ".notify()", "contracts_notifier")) in inc_calls # ── Same-file union / intersection receivers (user story 11, #9) ────────────── diff --git a/tests/test_watch.py b/tests/test_watch.py index a8c6b37f21..b2a462d472 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3384,14 +3384,19 @@ def test_incremental_indirect_call_parity_and_idempotency(tmp_path): 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: an interface-typed receiver survives an incremental rebuild -------- +# The wrong edge #11 closed: an `App\Contracts\Notifier`-typed receiver binding +# to the unrelated `App\Support\Notifier` class because the interface's own file +# was unchanged and therefore never re-dispatched, leaving the stranger as the +# one visible definition. The facts the resolver needs about an undispatched +# file come back through the persisted graph — the markers on its file node, the +# same channel `_callable` uses (#2438). +# +# Post-#47/#53 the interface has a definition node and its own `notify()`, so +# the receiver BINDS THE CONTRACT rather than refusing; the stranger is still +# what must never be bound, and the rebuild must still agree with the full build +# (now via the `_php_class_fqns` marker, #23, which carries the declared FQN the +# `use`-claim guard compares against). _11_CALLER = ( " Date: Fri, 7 Aug 2026 14:57:12 -0300 Subject: [PATCH 40/41] chore: bump to 0.9.39; changelog for the call-resolution + endpoint-scoring fixes (#52/#53/#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CHANGELOG bullets landed with each fix commit; this rolls the version so the version-namespaced AST cache (graphify-out/cache/ast/v{version}/) invalidates — #52 and #53 are extraction-side and a same-version rebuild would replay pre-fix raw_calls untouched. uv.lock version line edited by hand, mirroring 1606462: a full uv lock churns unrelated lines. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bfb6462272..0693926dd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.38" +version = "0.9.39" 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 91c995831f..2457923de3 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.38" +version = "0.9.39" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From a79ebe04fea1062c4a916958814f32c61717c401 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Tue, 11 Aug 2026 09:39:04 -0300 Subject: [PATCH 41/41] chore: consolidate CHANGELOG under 0.9.40 for the single upstream PR (#2615) - CHANGELOG: replace the fork's per-release sections and sync bookkeeping with one 0.9.40 (unreleased) section that holds only the fork-authored changes, grouped and ordered as they landed on the fork. - pyproject: 0.9.41 -> 0.9.40 (one bump over upstream v0.9.39; rolls the version-namespaced AST cache that the extraction-side fixes require; uv.lock already reads 0.9.40). - docs/how-it-works.md: interface/enum/trait-typed receivers bind to the declaration's own method since lawnstarter/graphify#53; the refusal list was stale. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 117 +++++++++++++------------------------------ docs/how-it-works.md | 2 +- pyproject.toml | 2 +- 3 files changed, 37 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 400dff7c23..ceec0d25e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,74 +2,20 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) -## 0.9.41 (unreleased) - -- Sync: upstream `Graphify-Labs/graphify` **0.9.38 and 0.9.39** are absorbed into the fork (upstream `50556ba`, tag `v0.9.39`). Both upstream sections are reproduced verbatim below, each under an `(upstream, …)` heading, because the fork's own line already numbers a 0.9.38 and a 0.9.39 — the third occurrence of the collision the fork-only 0.9.35 section documents, and the reason this sync is numbered 0.9.41 rather than reusing either number. Upstream's `## 0.9.37` heading is re-dated to `2026-08-08` below, since upstream dated it when they cut 0.9.38. Taken wholesale, the fork having no competing edits there: the sibling-callback local scoping (#2568), Kotlin property-initializer calls (#2565), Swift `@Environment`/factory receiver typing (#2561), the SQL CTE `reads_from` refusal (#2577), the JS dynamic-import rescue and its `affected` traversal (#2575, #2584), the one-token fuzzy-dedup guard (#2576), the `watch` doc-only deletion rebuild (#2580), and the ObjC category fold and property/ivar receivers (#2590, #2591). -- Sync: both code conflicts were **independent refusals narrowing the same expression**, and both sides are kept — neither weakens the other, because each only ever removes candidates. Unlike the 0.9.40 sync, **no fork test needed editing**: the suite goes from 4404 passed / 37 skipped to 4478 passed / 42 skipped, the delta being upstream's new tests, all green on the fork's resolution. -- In `extractors/engine.py`, the member-call defer condition gains upstream's `_python_defer` (#2586) alongside the fork's `_php_defer` (#1682/#9), as `if _python_defer or _java_defer or _php_defer or (…)`. The two disjuncts are mutually exclusive by construction rather than by luck: `_python_defer` tests `config.ts_module == "tree_sitter_python"`, and `_php_receiver_type` / `_php_multi_typed_receiver` — the only two inputs to `_php_defer` — are assigned solely inside the `config.ts_module == "tree_sitter_php"` branch, so exactly one of them can be true for any given call site and the added disjunct cannot change the fork's PHP verdicts. The fork's carve-out further down the condition (`config.ts_module != "tree_sitter_php"`, which keeps the capitalized-receiver rule off PHP) is untouched. -- In `extract.py`, the ObjC receiver-type index keeps the fork's language scoping AND gains upstream's protocol exclusion, as `_is_owned_definition(n, _OBJC_RESOLVER_SUFFIXES) and n.get("id") in contained and _is_type_like_definition(n) and not _is_protocol_declaration(n)`. Upstream narrowed the base predicate by adding `not _is_protocol_declaration(n)` to a `n.get("source_file")` truthiness test; the fork had already replaced that same test with `_is_owned_definition`, which is **strictly stronger** — it requires the source file to end in an ObjC suffix, and a node with no source file ends in no suffix — so keeping the fork's gate discards nothing upstream added, and the protocol exclusion is simply ANDed on. The composition is positively exercised rather than vacuous: upstream's three new ObjC suites write `.h`/`.m` fixtures and assert edges that only resolve if the ownership gate admits them, while `test_objc_protocol_only_receiver_emits_no_call_edge` pins the exclusion. -- The package version bump to 0.9.41 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing on the fork's side changes extraction in this release, but most of upstream's 0.9.38/0.9.39 does — the JS dynamic-import rescue, the Kotlin, Swift, SQL, ObjC and Python extractor work are all extraction-side — so a corpus with JS/TS, Kotlin, Swift, SQL, Objective-C or Python sources should be re-extracted (`graphify update .`) to pick them up. A PHP-only corpus is unaffected by this sync beyond the cache roll. - ## 0.9.40 (unreleased) -- Sync: upstream `Graphify-Labs/graphify` **0.9.36 and 0.9.37** are absorbed into the fork (upstream `09a34ad`, tag `v0.9.37`). Both upstream sections are reproduced verbatim below, each under an `(upstream, …)` heading because the fork's own line already numbers a 0.9.36 and a 0.9.37 — the same collision the fork-only 0.9.35 section documents, and the reason this sync is numbered 0.9.40 rather than reusing either number. Taken wholesale, the fork having no competing edits there: the four silent-success-exit fixes and the `cluster-only` refused-write guard (#2534, #2522), the Swift cross-file extension remap (#2538), deterministic active-over-archived node-id collision ranking (#2532), the PowerShell Windows skill variant and its generator parity check (#2528), the JS/TS callback-body walk (#2552), the Kotlin grammar/import/qualified-call/parse-error work (#2526, #2550, #2551), the `graphify update` failed-extraction retry (#2543), and the claude-cli stdout-envelope error surfacing (#2554). -- Sync: upstream's TypeScript member-call gating (#2553) landed in `_resolve_typescript_member_calls`, the same function the fork's cross-language isolation work rewrote (`lawnstarter/graphify#24` and `#10`). Both survive, and they compose because both are **refusals**: the fork's `_is_owned_definition` / `_raw_call_is_owned` gates keep the resolver from matching a receiver type against a definition written in another language, and upstream's origin gate additionally requires the matched type to be visible to the caller's file (same file, a named import, or a module the file imports). Upstream's two other changes to that function are adopted unchanged: the `references`-edge fallback for a typed receiver whose type has no such method is dropped (it was a fabrication vector), and a table-typed receiver is tiered INFERRED 0.8 while a source-written `Type.method()` stays EXTRACTED 1.0. Nothing the fork refused before is now emitted, and nothing the fork emitted through a same-language, caller-visible type is now refused. -- Behavior change riding on that gate, and the one fork test the sync had to edit: a TypeScript member call through a **constructor-injection-typed** receiver (`constructor(private lead: Lead) {}` then `this.lead.search()`) is now **INFERRED 0.8 rather than EXTRACTED 1.0**, and it requires the calling file to import the type. `tests/test_mixed_corpus_member_calls.py::test_typescript_receiver_resolves_despite_a_same_named_python_class` asserted the old confidence on a fixture whose `runner.ts` named `Lead` without importing it — invalid TypeScript that only resolved because the pre-gate resolver matched on name alone. The fixture now carries the `import { Lead } from './lead';` real TypeScript would require, which is what the test was always about (the Python decoy must not suppress the real TS edge, `lawnstarter/graphify#24`) and which still holds: the edge resolves and the Python `Lead.search` still gets nothing. The negative sibling test keeps the import-less fixture on purpose, so it goes on pinning the fork's definition-index scoping on its own evidence rather than passing for upstream's reason. The tiering also brings TypeScript in line with the Swift sibling test, which has always asserted INFERRED. -- Sync: the two remaining conflicts were additions at a shared insertion point, resolved by keeping both sides. In `extractors/engine.py` the fork's PHP raw-call markers (`php_function_call`, `fcc`, `receiver_type`, `receiver_type_qualified`, `receiver_qualified`) and upstream's Kotlin `qualified_prefix` stamp now sit side by side on the same `rc_entry`, and their local declarations likewise; the two never touch the same key. In `extract.py` the fork's `_is_php_function_target` helper (`lawnstarter/graphify#52`) and upstream's early `_KOTLIN_IMPORT_TARGET_RESOLVER` run are both kept, with the Kotlin run left immediately above the import-evidence index it must precede. -- The package version bump to 0.9.40 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction on the fork's side, but upstream's 0.9.36/0.9.37 do — the Swift extension remap, the Kotlin extractor, the JS/TS callback walk and the TS origin gate are all extraction-side — so a corpus with Swift, Kotlin, JS/TS or Java sources should be re-extracted (`graphify update .`) to pick them up. - -## 0.9.39 (unreleased) - -- Fix: a PHP **function call never binds to a method** across files (`lawnstarter/graphify#52`). In PHP a bare `name(...)` — a `function_call_expression` — can only invoke a global or namespaced *function*; reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, each of which takes a different path through the extractor. The shared cross-file pass matched by normalized label, and that normalization (`raw.strip("()").lstrip(".")`) erases the member marker the engine writes — a method labeled `.event()` and a function labeled `event()` both key as `event` — so Laravel's `event(...)` helper bound to whichever class happened to declare an `event()` method. On the pinned 46,406-node api.lawnstarter.com corpus that is **848 fabricated inbound `calls` edges on `GetProviderBillingEventsTest::event()`**, a test method credited as the callee of 368 distinct source files, 308 of them not tests. It was not even an ambiguity the god-node tie-breakers could catch: the three `Event` *classes* key as `Event`, so the method was the **sole** exact-case candidate and bound through the single-candidate path at INFERRED 0.8, before any tie-break could apply. The refusal is language-semantic rather than heuristic — a method is simply not a candidate at a function-call site — so it is applied to the candidate **list**, not by re-keying the indexes, which covers every consumer at once: the single-candidate bind, the symbol/module import-evidence disambiguation, and the god-node tie-break. The same language-semantic argument extends past methods: a **class-like declaration is not invocable either** — `report($e)` where only `class Report` exists is a "Call to undefined function" fatal, not a constructor call — so class, interface, enum and trait nodes are refused at a function-call site alongside methods, leaving only plausible function targets. That half also fixes a pre-existing bind this release would otherwise have inherited: `foo(...)` matching a cross-file `class Foo` through the case-insensitive fold has always produced a `calls` edge into the class. Both index paths are covered, and the folded lookup is *retried* after the method/class refusal rather than skipped: the case-insensitive fallback only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise have hidden a real `function Event()` reachable solely under the folded key, and the site would have resolved to nothing at all. The retry has to carry the refusal with it, or it hands the folded index a candidate the exact-case path never offered — a capitalized class under the shadowed lowercase key, which is exactly the **7 `report(...)` → `class Checkr\Resources\Report` edges** measured on the rebuilt 46.6k-node corpus. The class discriminator is the `_callable_class` marker the `indirect_call` guard already reads, chosen for the same reason as the label one: it is replayed onto `resolution_context_nodes` by both `graphify update` and `watch`, so it still classifies unchanged-corpus candidates on an incremental rebuild. The call site is identified by a marker the PHP extractor stamps on the raw call, which is load-bearing rather than convenience: a `scoped_call_expression` is also not a member call, so refusing on "PHP and not a member call" alone would have silently widened the policy to `Class::method()`. The method-vs-function discriminator is the engine's own label convention (`.name()` for a member, `name()` for a top-level function), chosen because a label persists into `graph.json` and so still classifies the unchanged-corpus candidates an incremental rebuild hands back as resolution context (#2406) — an edge-derived test would not, since the shared pass never receives those edges. PHP-only by construction: a Ruby or Python bare call reaches a method through implicit self, so their candidate filtering is deliberately untouched, as is the member-call resolver (`_resolve_php_member_calls`). One residual is documented rather than chased: a `event(...)` call in the *same file* as a class declaring an `event()` method still binds to it, because the extractor resolves that raw call in-file and it never reaches the cross-file pass — unchanged behavior, and the reason the measured fan-in shrinks to near-zero rather than provably zero. Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to shed these edges, and re-extraction genuinely requires the release's version bump to roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`): entries are keyed by content hash *within* that namespace, so a same-version rebuild replays the pre-fix raw calls untouched. -- Fix: `graphify query` and `graphify path` / `shortest_path` no longer resolve an endpoint to a **sourceless stub** when a real declaration carries the same label (`lawnstarter/graphify#54`, closing the gap `#49` left behind). `#49` taught `_find_node_tiers` and `affected`'s `resolve_seed` to drop stubs from a mixed exact tier, so `explain` and `affected` agree on the sourced declaration — but the scored path those two commands do not share, the `_score_nodes` ranking that feeds `_pick_scored_endpoint` (path) and `_pick_seeds` (query), never learned the rule. On the two-node repro — `FooRepository` at `source_file: ""` beside `FooRepository` at `app/bindings.php` — both nodes score **5619.081861425897**, identically, so the ranking fell through to its lexicographic node-id tie-break and answered with whichever id sorted first: `explain` named the declaration while `query` traversed the stub, reinstating for `explain`-vs-`query` exactly the divergence `#49` closed for `explain`-vs-`affected`, and violating the design intent the scorer's own comment states — that `path` and `query` resolve the same node `explain` does. The stub is rarely a merely cosmetic mismatch, because it is usually the *disconnected* node: `shortest_path` answered a reachable query with a false "No directed path found", and `query` spent its traversal on the stub's neighborhood instead of the declaration's. The fix is a **tie-break, not a scoring penalty**, and the distinction is load-bearing: a sourceless node can never *out*-score an otherwise-identical sourced one, since the only source-derived scoring term is additive and source hits do not count toward term coverage, so a shadowing stub always arrives at the sort as an exact tie and a tie-break is the complete and minimal intervention. The sourced preference is inserted directly under the score in both tie-break keys — the combined ranking sort and the per-term singleton-winner key that feeds the seed guarantee — above the label-length and node-id keys, which are arbitrary with respect to which node is real. No live score changes, so every score-sensitive baseline holds unedited. `#49`'s carve-out survives by construction rather than by a special case: an all-sourceless field has nothing to prefer, so a lone stub still wins its query and nothing that answered before now returns nothing. One asymmetry with `_find_node` is deliberate and left in place, since `_find_node` is unchanged here: it demotes stubs in its *exact* tier only, while a score tie can also arise from the prefix and substring tiers — on those shapes the scored path now prefers the sourced node where `_find_node` still answers by graph-iteration order, which is the more deterministic of the two answers, and closing the remaining gap would mean changing `_find_node` itself. Serve-side only: nothing in extraction, merge or the graph format changes, so an existing graph picks this up with **no rebuild and no re-extraction**. -- Fix: a PHP receiver typed with an `interface`, `enum` or `trait` name now binds to that declaration's own method instead of being refused outright (`lawnstarter/graphify#53`). `_resolve_php_member_calls` consulted a corpus-wide pre-scan of the non-class declaration names (`_php_non_class_types`) and skipped every such receiver — correct while the three minted no definition node, because the only thing such a receiver could land on was a same-short-named CLASS: Laravel's `App\Contracts\Notifier` interface beside an unrelated `App\Support\Notifier`, or `App\Enums\Status` beside an Eloquent `App\Models\Status`, each leaving exactly ONE definition under the short name and so satisfying the single-definition guard with the wrong answer. `#47` (0.9.38 above) gave all three kinds canonical sourced nodes that their own methods attach to, which makes the refusal redundant: the collision now presents TWO definitions and is refused by the guard unaided, while a `use`-imported name is decided by the declared-FQN match. What the refusal was still costing is the rest — an interface or enum named by exactly one declaration, and a `use`-claimed one whose import says which of the namesakes it means — so `$this->notifier->send()` on a `private Notifier $notifier` now lands on `Notifier::send()` itself. Implementations are still never guessed: a `MailNotifier implements Notifier` gets nothing from an interface-typed receiver, and the same-short-named stranger class gets nothing on any path. -- The lift required extending the declared-FQN pre-scan to the three kinds, and that half is a fix in its own right: `_php_pre_scan_class_namespaces` recorded `class_declaration` only, so an interface/enum/trait node NEVER carried a declared FQN — not on a full build, not through the `_php_class_fqns` marker (`#23`). `PhpNameResolver`'s `use`-claim guard then fell back to comparing the node's PSR-4 PATH, and `_php_fqn_names_another_class` treats a path with FEWER segments than the written name as *no evidence* and keeps the edge. On a full build the paths are still absolute at resolver time, so the comparison ran and refused; on an incremental rebuild the replayed context node carries the relativized `app/Contracts/Notifier.php` (3 segments), so `use Illuminate\Contracts\Notifications\Notifier;` (4) tripped the bail-out and bound the in-corpus interface that the vendor import provably does not name — a wrong edge of the `#16` class, appearing ONLY under `graphify update`/`watch`, which is the normal operating mode. `Illuminate\Contracts\\` is exactly 4 segments and Laravel apps routinely declare `App\Contracts\{Repository,Factory,Guard,Mailer,…}` beside imports of the framework contracts with those same short names, so the shape is ordinary rather than exotic. All four declaration kinds are now read into `php_class_fqns` / `_php_class_fqns` / `fqn_def_nid`, which makes the guard whole-name and decisive on both build paths and closes the full-vs-incremental parity hole; the `tests/test_php_member_calls.py` cases assert the two builds' verdicts TOGETHER, since a full-build-only assertion is green on the broken code. -- The `_php_non_class_types` / `_php_interfaces` channel is kept intact — pre-scan, per-file payload, file-node stamp, `graphify update`/`watch` replay, both spellings read back — so graphs written by earlier versions still round-trip. Nothing consumes it for resolution any more; it remains the only record of declaration KIND that survives into a graph. -- Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to pick this up: the new edges come from the receiver-typing pass and the declared FQNs come from a pre-scan, and AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild replays the pre-fix payloads untouched. - -## 0.9.38 (unreleased) - -- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. -- Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. A PHP corpus must therefore be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. -- Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. -- Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. -- Fix: a PHP `parent::`, `self::` or `static::` call no longer names its **scope** as the callee, so it can no longer bind to an unrelated method that merely shares that word as a label (`lawnstarter/graphify#39`). The `scoped_call_expression` handler read the scope text as the callee name — right for `Helper::format()`, wrong for the three relative scopes: `parent::setUp()` minted a raw call to a callee literally named `parent`, and since no in-file definition answers that name, the fact reached the cross-file pass, which matches by normalized label — so it bound to whatever `->parent()` accessor the corpus happened to hold. On the pinned 46,406-node api.lawnstarter.com corpus that is **1,698 fabricated inbound `calls` edges on `ServiceCategory::parent()`**, a model accessor credited with calling the `setUp()` of test classes across the corpus. Which class a relative scope denotes needs the inheritance context the raw-call facts do not carry, so the extractor refuses rather than guesses, gated on `_PHP_NON_CONCRETE_TYPE_NAMES` — the same set the `(new X())->m()` branch already applies. Absolute scopes (`Helper::format()`, `\App\Foo::bar()`) are unaffected, and the refusal drops the fabricated edge without inventing a replacement: binding a relative scope to its real target needs the resolved base class, which is a separate piece of work. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to shed these edges. -- Fix: a PHP property assigned in the **constructor body** from a typed constructor parameter now types the `$this->prop->method()` receiver, so the call resolves to that parameter's class (`lawnstarter/graphify#38`). PHP receiver typing read the class's typed properties and constructor-promoted params only, which misses the pre-promotion idiom that dominates a Laravel corpus: the property is declared untyped with its type in a `@var` docblock, and the collaborator arrives as a typed `__construct` parameter that the body assigns across (`$this->chargeCustomerService = $chargeCustomerService;`). Measured on the 48.5k-node Laravel corpus, `BalanceCustomerAccountService::handle()` minted no `calls` edge to `ChargeCustomerService::handle()` while its two sibling callers — one using a promoted param, one a typed method param — resolved fine. The **parameter's signature is the evidence and `@var` docblocks are still not read**: a docblock-only property with no constructor assignment types nothing, and where docblock and signature disagree the signature wins. The refusal discipline is the local-variable one: the property is poisoned rather than guessed at when its binding is not provably single-typed — a second assignment of a different or untypable type (a container resolve, an inline `new`, a local, a literal), an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body, including by a shadowing closure parameter. Because a property is not a local, that refusal is judged over the whole **class**, not just the constructor: a write to the property from any other method — a setter, a `boot()`, a closure captured anywhere — refuses the constructor's binding, since it is a deferred write that may run never, later, or repeatedly, and an *untyped* property (unlike a natively typed one, which PHP fatals on an incompatible write) carries no runtime guarantee that would make it safe. The refusal is unconditional on the write and not conditional on the written type, so even a same-typed setter costs the edge — deliberate, and the trade this discipline makes everywhere else too. A real annotation always outranks the assignment, so a natively typed or promoted property keeps its declared type and a union-typed one keeps its multi-class refusal (#9); every downstream refusal — interface/enum/trait receivers (#5, #12), the `use`-claimed out-of-corpus name (#16), the single-definition guard — applies unchanged, since this only fills in the receiver type the resolver was already asking for. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to pick the new edges up. -- One related defect is deliberately NOT fixed here and keeps its own issue: the member-call resolver still REFUSES to bind a receiver typed by an interface, enum or trait (the refusal described in the fork's 0.9.35 section below). The collision it was built for — `App\Contracts\Notifier` beside `App\Support\Notifier` — now presents two definitions and would be refused by the single-definition guard on its own, so the pre-scan is now redundant conservatism rather than a workaround for a missing node, but lifting it is a behavior change on its own merits. That fan-in stays unrealized for now. The second defect `#47` originally deferred — an `imports` edge whose target never got a stub node — is not deferred after all: `lawnstarter/graphify#48` ships in this same release (bullet above), and the two compose exactly as anticipated, since `#47` supplies the declaration node `#48`'s resolution needs to land on. -- The package version bump to 0.9.38 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does, and here that roll is load-bearing rather than incidental: four of the five fixes above are extraction-side (only `#49` is serve-side), and AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild would replay the pre-fix nodes untouched. A PHP corpus must be re-extracted (`graphify update .`) to pick this release up; the graph format itself is unchanged. - -## 0.9.37 (unreleased) - -- Sync: upstream `Graphify-Labs/graphify` **0.9.35** is absorbed into the fork (upstream `9f25a3a`, tag `v0.9.35`). Its `build_merge` shrink-guard and absolute-path prune fixes (#2497, #2446), the `graphify update` newly-ignored-file eviction and Java local-class/external-annotation split (#2495, #2504), and the direction-aware `graphify callflow` (#2508) are taken wholesale — the fork had no competing edits there. Every upstream 0.9.35 bullet is reproduced verbatim in its own section below. -- Sync: upstream's own take on the relational-verb seeding defect (#2507, filed from this fork) collided with the fork's `lawnstarter/graphify#37` C1 fix in `graphify/serve.py`; the fork's structure wins and upstream's vocabulary is absorbed into it, so no demotion either side shipped is lost. Upstream demotes a hardcoded verbs-only list inline at the `_query_graph_text` call site; the fork derives `_RELATIONAL_INTENT_TERMS` from the two tables that already encode relational intent and demotes through the `_demote_relational_intent_terms` helper, which keeps the map-keyed all-relational fallback (`graphify query "calls"` keeps its guarantee) and composes with the fork's `skip_covered_terms` (#37/C2) and heuristic-filter relaxation (#37/C3). The derived set now unions in the twelve words upstream demoted that neither table yields — `depend`, `depends`, `export`, `extend`, `extends`, `extended`, `implement`, `implements`, `implemented`, `reference`, `references`, `referenced` — making the fork's vocabulary a strict superset of both parents' (62 words vs upstream's 29). Upstream's five #2507 tests are adopted verbatim and pass against the fork's implementation unchanged. -- Behavior change: **`caller` and `callers` now infer the `call` context filter**, adopting upstream's `_CONTEXT_HINTS` addition. This supersedes the "the hint table itself is untouched, so which queries get context-filtered is unchanged" note in 0.9.36 below: "callers of X" now takes the filtered path that "who calls X" already took. On a bare class node that is exactly the stranding case #37/C3 was built for — a class owns no `call` edges — so the filter reaches nothing beyond the seeds, the traversal relaxes to unfiltered, and the header says `Context: call (heuristic; relaxed — no matches beyond seeds)`. The answer is unchanged in content (all 3 callers still render on the 48.5k-node Laravel corpus); only the header note is new. Upstream shipped this hint without the relaxation fallback, so on upstream the same query strands; on the fork it does not. -- Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip 0.9.36 shipped (`lawnstarter/graphify#37` C2; adopted from the `Graphify-Labs#2516` review, upstream `234c5f0`). 0.9.36 let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. -- The package version bump to 0.9.37 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does. Nothing in this release changes extraction, merge or the graph format on the fork's side, but upstream's 0.9.35 does touch extraction (the Java type-resolution fix, #2504) and `graphify update`, so a corpus with Java sources should be re-extracted (`graphify update .`) to pick that up. - -## 0.9.36 (unreleased) - -- Fix: a relational word in a natural-language `graphify query` — "who **calls** X", "**callers** of X", "what **uses** X" — no longer buys an unrelated decoy a guaranteed seed slot (`lawnstarter/graphify#37`, upstream #2507). Such a word names the *relation* the question asks about, not a symbol to start from, but the per-term seed guarantee (#1445) hands every query term an unconditional seat that bypasses the gap cutoff — so a `callStoreWithAmount()` test helper, which prefix-matches "calls", entered the traversal beside the real identifier and spent the whole token budget on its test neighborhood. Relational words now forfeit that unconditional seat, **and nothing else**: they are NOT stopworded (upstream deliberately pinned "calls" as a kept content term in #1597), they still score, and they still win seeds through the gap window on merit, so a corpus symbol literally named `calls` or `uses` keeps its exact-match dominance and stays seedable — as it always was through `explain`, `path` and `find_node`, none of which share this code path. The vocabulary is derived from the two tables that already encode relational intent (`_CONTEXT_HINTS` words plus `_CONTEXT_FILTER_ALIASES` keys), extended with the caller/use/listen families because "callers" and "uses" appear in neither table yet carry the same intent; the hint table itself is untouched, so which queries get context-filtered is unchanged. A query made of nothing *but* relational words (`graphify query "calls"`) keeps its guarantee unfiltered — it has no identifier to seed from — mirroring the existing all-stopword fallback in query-term extraction. -- Fix: a query term that an already-picked seed's label plainly matches no longer claims a second, redundant guaranteed seed (`lawnstarter/graphify#37`). The #1445 guarantee exists to stop one term's incidental exact match from starving the other terms, and that protection is unchanged for a genuinely starved term; what changes is the invariant it enforces, from *every term's own singleton winner gets a slot* to *every term with any match is matched by at least one seed*. A term that is a substring of a picked seed's normalized label — the same predicate as the scorer's weakest match tier, so the seed provably would have matched the term in scoring — is by definition not starved. Without the skip, each generic noun in a question dragged in its own hub: "what code uses ChargeCustomerService to charge a customer" seeded the `Customer` model and a `.charge()` method on top of `ChargeCustomerService`, whose label already matches both terms, and the hub's member fan-out then flooded the traversal (a measured 2,307-node traversal on a 48.5k-node corpus). Coverage is judged on the seed's normalized **label** alone and never on its node id: a labelless node is real (an alias-only node enters the graph with no label) and is still seedable through the source-file scoring tier, so reading its id's path fragments would declare unrelated terms covered on a match the scorer never made — and starve the term's real winner, breaking the very invariant this refines. The check runs after the existing homonymous-label dedup gate (#1766) and in the same sorted-term order, so a generic label still contributes at most one seed. -- Fix: a *heuristically inferred* context filter that reaches nothing beyond the seeds now relaxes to an unfiltered traversal instead of returning a confident near-empty answer (`lawnstarter/graphify#37`). "Who calls ChargeCustomerService?" infers a `call` filter, but a class node owns no call edges — calls attach to its methods, and the class-to-member edge carries no context — so the filtered traversal could not leave the seed even with perfect seeding. The threshold is deliberately zero expansion (the traversal discovered nothing at all, which needs no tuning constant; a filter that found *something* is doing its job), it applies identically in breadth-first and depth-first mode, and the header says so — `Context: call (heuristic; relaxed — no matches beyond seeds)` — so a relaxed answer never reads as a filtered one. An **explicitly** requested `--context` filter is an instruction, not a guess, and is honored even when it strands the seeds. -- Serve-side only: these three changes are opt-ins wired exclusively from the natural-language query pipeline, so the scorer stays byte-identical (its legacy-equality property tests and the query-scoring benchmark's equality gate hold without edits) and every other caller of the seed picker is untouched by construction. Nothing in extraction, merge or the graph format changes, so no rebuild or re-extraction is needed to pick this up — though the package version bump does roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does, which for this release only means the next file dispatched for extraction is re-parsed rather than served from cache. -- Together these three take the motivating impact questions from wrong-but-confident to correct on a real 48.5k-node Laravel corpus, where the queried service has exactly 3 grep-verified production callers: "Who calls ChargeCustomerService?" rendered 0 of the 3 (a budget's worth of the decoy's test neighborhood instead), "callers of ChargeCustomerService" found all 3 but buried them in an unrelated seed's haystack, and "what code uses ChargeCustomerService to charge a customer" rendered 0 of 3 out of a multi-thousand-node traversal explosion; all three now render all 3 callers within the default token budget. One residual is documented rather than chased: a generic noun like "code" can still guarantee a seed for a doc file that merely prefix-matches it (a `coder.md`), contributing a small minority of the traversal — below every acceptance bar, and complementary upstream work on test-path seed exclusion (#2381) and bounded per-term recovery (#2113) would shrink it further. +Consolidated PHP-support and resolution work from the [`lawnstarter/graphify`](https://github.com/lawnstarter/graphify) fork — #1682 and everything found while fixing it. The full defect report is #2615. The groups below are ordered as the work landed on the fork, so a later group can lift a refusal an earlier group states; those spots are marked. -## 0.9.35 (unreleased, fork-only) - -> Number collision with upstream's released `v0.9.35` (dated section below), an -> artifact of the fork numbering its own line while upstream numbered theirs. -> This section is the fork's 0.9.35 — the PHP receiver-typing work — and was -> never tagged or published; upstream's 0.9.35 content only reaches this branch -> in 0.9.37 above. +**PHP member-call resolution, cross-language isolation, and import-edge repair:** - 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. +- 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 (this refusal is lifted by the resolution-gaps group below, once interface/trait/enum declarations mint their own nodes); 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 Java and C# halves are fixed by the next bullet, and the C++, Swift, TypeScript and Python halves by the one after it. - Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is closed by the next bullet. - Fix: the last four receiver-type indexes — C++, Swift, TypeScript and Python — are now scoped to their own sources too, so no member-call resolver matches a receiver's declared type against class definitions written in another language any more (`lawnstarter/graphify#24`). This is the same two-way defect the PHP/ObjC and Java/C# bullets above describe, in its remaining copies: a C++ `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED, a Swift `let lead: Lead` and a TypeScript `private lead: Lead` did the same, and — in the other direction — 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 LOSE these cross-language edges, which were always wrong, and GAIN C++, Swift, TypeScript and Python edges that a foreign short-name collision previously deleted. Single-language corpora are unaffected: the filter admits everything such a corpus contains. Two notes on the edges of this change. `.h` is scoped into BOTH the C++ and the Objective-C index, because it routes to either extractor by content and a C++ class and an ObjC `@interface` both live in a header — the two languages are therefore isolated from every other language but not from each other, which no suffix can fix, and raw-call ownership for them stays on the extractor-stamped `lang` for exactly that reason. And Python is scoped on both of its arms, not just the class index: its `module.func()` arm matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound the call to a TypeScript function at EXTRACTED. All ten resolvers now share one `_is_owned_definition` predicate keyed off the same per-resolver suffix tuple that registers the resolver, so the registration and the scoping cannot drift. Pre-existing, not a regression. - Fix: an `imports` edge no longer vanishes when any same-stem file sits beside its target, in Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash (`lawnstarter/graphify#33`). Each of these names an import's target by the imported file's bare stem id (`import lead` -> `lead`), which resolves only while that id is unique: add a `lead.md` and the two file nodes collide, so id-disambiguation salts them into `lead_py_lead` and `lead_md_lead` while the edge — keyed by the importer's own file rather than the target's — was left pointing at an id that no longer named anything, and was dropped along with everything downstream of it (Python's `module.func()` call resolution among them). These edges now stamp the `target_file` hint the disambiguator already accepts for this (#1814), so the salt lands on the right file; an import written in one language can only mean a file of that language, so the choice stays unambiguous whatever the collider is. The hint is stamped centrally rather than in each extractor, because an extractor sees one file and cannot know which of the corpus's same-stem candidates the id will end up naming; it is transient and popped by its only reader, so it never reaches `graph.json`. An id claimed by more than one importable file of the same language is left dangling, as before, and a corpus with no collision is bit-identical — the hint only ever selects among the salted variants of an id the edge already named, so it cannot change WHICH node an edge resolves to. Bash additionally emitted the edge twice under a collision — once correct, once dangling — because its second producer in `resolve_bash_source_edges` derives ids from the path after disambiguation has already renamed them; that pass now reads the ids as they actually stand, which also repairs the source-backed `calls` edges it resolves. TypeScript/JavaScript and C/C++/Objective-C already had equivalent protection; Julia, Fortran and Verilog target an importer-scoped node and were never exposed; Dart mints its own stub nodes and Ruby/PHP emit no file-targeting import edges. Pre-existing, not a regression. -- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. +- 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 (lifted by the later groups below, once enums mint declaration nodes and enum-typed receivers bind); and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. @@ -77,15 +23,36 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. - Feature: a PHP member call through a receiver whose type name the calling file claims now BINDS to the class whose defining file declares exactly that fully qualified name (`lawnstarter/graphify#22`), completing the `use`-map work above with its recall half. Two things resolve that used to refuse: `use App\Alpha\X;` with `private X $c;` selects `App\Alpha\X` among several same-short-named in-corpus classes (the single-definition guard previously refused the pair), and a renaming alias `use App\Alpha\X as Y;` with `private Y $c;` follows the alias to its target, which no short-name census could find because nothing in the corpus is named `Y`. The match is against the namespace each defining file DECLARES (the same #14 facts the inline-`new` corroboration reads), folded case-insensitively like every other PHP name; an FQN declared identically by two files poisons that entry rather than guessing between them. **Addition scope**, stated deliberately as the mirror of the deletion scope above: this change only ever adds or re-points `calls`/`indirect_call` edges whose receiver type the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — and whose claimed FQN matches a declared one exactly. An unclaimed short name still resolves through the same unique-short-name fallback as before, and every #16-style refusal stands where no declared FQN matches. New edges are INFERRED (0.8), like every receiver-typed binding. - To make those bindings survive `graphify update`, each PHP file node now persists the FQNs its classes are declared under as a `_php_class_fqns` marker in `graph.json` (`lawnstarter/graphify#23`), riding the same channel as `_callable` (#2438) and `_php_non_class_types` (#11/#12): the watcher and `graphify update` hand the marker back on the resolution-context nodes, and the resolver rebuilds its declared-FQN index from it for every file the incremental pass left untouched. Without this, every edge #22 adds would exist on full builds only and silently vanish on the first incremental rebuild — the exact failure mode #11 closed for the interface refusal. A `graph.json` written before the marker fails closed, matching the established precedent: the binding is simply absent (never redirected to a namesake, and the #16 refusals still stand on path evidence) until the defining file is re-extracted or `graphify update` runs on it once. -- The package version bump to 0.9.35 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.39 (upstream, unreleased) +**Natural-language query seeding (#2507 follow-ups):** + +- Change: the relational-verb demotion that 0.9.35 shipped for #2507 is restructured and widened (`lawnstarter/graphify#37`). A relational word in a natural-language `graphify query` — "who **calls** X", "**callers** of X", "what **uses** X" — names the *relation* the question asks about, not a symbol to start from, so it forfeits the unconditional per-term seed slot (#1445) — **and nothing else**: relational words are NOT stopworded (upstream deliberately pinned "calls" as a kept content term in #1597), they still score, and they still win seeds through the gap window on merit, so a corpus symbol literally named `calls` or `uses` keeps its exact-match dominance and stays seedable. The vocabulary is now derived from the two tables that already encode relational intent (`_CONTEXT_HINTS` words plus `_CONTEXT_FILTER_ALIASES` keys), extended with the caller/use/listen families, and unioned with the twelve words the previous hardcoded list carried that neither table yields — a strict superset of the 0.9.35 set (62 words vs 29). Demotion runs through a `_demote_relational_intent_terms` helper that keeps the map-keyed all-relational fallback, so a query made of nothing *but* relational words (`graphify query "calls"`) keeps its guarantee unfiltered, mirroring the existing all-stopword fallback in query-term extraction. The five #2507 tests pass against the restructured implementation unchanged. +- Fix: a query term that an already-picked seed's label plainly matches no longer claims a second, redundant guaranteed seed (`lawnstarter/graphify#37`). The #1445 guarantee exists to stop one term's incidental exact match from starving the other terms, and that protection is unchanged for a genuinely starved term; what changes is the invariant it enforces, from *every term's own singleton winner gets a slot* to *every term with any match is matched by at least one seed*. A term that is a substring of a picked seed's normalized label — the same predicate as the scorer's weakest match tier, so the seed provably would have matched the term in scoring — is by definition not starved. Without the skip, each generic noun in a question dragged in its own hub: "what code uses ChargeCustomerService to charge a customer" seeded the `Customer` model and a `.charge()` method on top of `ChargeCustomerService`, whose label already matches both terms, and the hub's member fan-out then flooded the traversal (a measured 2,307-node traversal on a 48.5k-node corpus). Coverage is judged on the seed's normalized **label** alone and never on its node id: a labelless node is real (an alias-only node enters the graph with no label) and is still seedable through the source-file scoring tier, so reading its id's path fragments would declare unrelated terms covered on a match the scorer never made — and starve the term's real winner, breaking the very invariant this refines. The check runs after the existing homonymous-label dedup gate (#1766) and in the same sorted-term order, so a generic label still contributes at most one seed. +- Fix: a *heuristically inferred* context filter that reaches nothing beyond the seeds now relaxes to an unfiltered traversal instead of returning a confident near-empty answer (`lawnstarter/graphify#37`). "Who calls ChargeCustomerService?" infers a `call` filter, but a class node owns no call edges — calls attach to its methods, and the class-to-member edge carries no context — so the filtered traversal could not leave the seed even with perfect seeding. The threshold is deliberately zero expansion (the traversal discovered nothing at all, which needs no tuning constant; a filter that found *something* is doing its job), it applies identically in breadth-first and depth-first mode, and the header says so — `Context: call (heuristic; relaxed — no matches beyond seeds)` — so a relaxed answer never reads as a filtered one. An **explicitly** requested `--context` filter is an instruction, not a guess, and is honored even when it strands the seeds. +- Serve-side only: these three changes are opt-ins wired exclusively from the natural-language query pipeline, so the scorer stays byte-identical (its legacy-equality property tests and the query-scoring benchmark's equality gate hold without edits) and every other caller of the seed picker is untouched by construction. Nothing in extraction, merge or the graph format changes, so no rebuild or re-extraction is needed to pick this up — though the package version bump does roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does, which for this release only means the next file dispatched for extraction is re-parsed rather than served from cache. +- Together these three take the motivating impact questions from wrong-but-confident to correct on a real 48.5k-node Laravel corpus, where the queried service has exactly 3 grep-verified production callers: "Who calls ChargeCustomerService?" rendered 0 of the 3 (a budget's worth of the decoy's test neighborhood instead), "callers of ChargeCustomerService" found all 3 but buried them in an unrelated seed's haystack, and "what code uses ChargeCustomerService to charge a customer" rendered 0 of 3 out of a multi-thousand-node traversal explosion; all three now render all 3 callers within the default token budget. One residual is documented rather than chased: a generic noun like "code" can still guarantee a seed for a doc file that merely prefix-matches it (a `coder.md`), contributing a small minority of the traversal — below every acceptance bar, and complementary upstream work on test-path seed exclusion (#2381) and bounded per-term recovery (#2113) would shrink it further. +- Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip above (`lawnstarter/graphify#37` C2; adopted from the #2516 review). The initial skip let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. + +**PHP node identity and lookup:** -> Number collision with the fork's own `0.9.39` section above, the same -> artifact as the `0.9.35`/`0.9.37` ones: the fork numbers its line while -> upstream numbers theirs. This is upstream's 0.9.39, absorbed into the fork -> in 0.9.41 above; the fork's own 0.9.39 is the call-resolution and -> endpoint-scoring release (`lawnstarter/graphify#52`/`#53`/`#54`). +- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. +- Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. A PHP corpus must therefore be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. +- Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. +- Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. +- Fix: a PHP `parent::`, `self::` or `static::` call no longer names its **scope** as the callee, so it can no longer bind to an unrelated method that merely shares that word as a label (`lawnstarter/graphify#39`). The `scoped_call_expression` handler read the scope text as the callee name — right for `Helper::format()`, wrong for the three relative scopes: `parent::setUp()` minted a raw call to a callee literally named `parent`, and since no in-file definition answers that name, the fact reached the cross-file pass, which matches by normalized label — so it bound to whatever `->parent()` accessor the corpus happened to hold. On the pinned 46,406-node api.lawnstarter.com corpus that is **1,698 fabricated inbound `calls` edges on `ServiceCategory::parent()`**, a model accessor credited with calling the `setUp()` of test classes across the corpus. Which class a relative scope denotes needs the inheritance context the raw-call facts do not carry, so the extractor refuses rather than guesses, gated on `_PHP_NON_CONCRETE_TYPE_NAMES` — the same set the `(new X())->m()` branch already applies. Absolute scopes (`Helper::format()`, `\App\Foo::bar()`) are unaffected, and the refusal drops the fabricated edge without inventing a replacement: binding a relative scope to its real target needs the resolved base class, which is a separate piece of work. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to shed these edges. +- Fix: a PHP property assigned in the **constructor body** from a typed constructor parameter now types the `$this->prop->method()` receiver, so the call resolves to that parameter's class (`lawnstarter/graphify#38`). PHP receiver typing read the class's typed properties and constructor-promoted params only, which misses the pre-promotion idiom that dominates a Laravel corpus: the property is declared untyped with its type in a `@var` docblock, and the collaborator arrives as a typed `__construct` parameter that the body assigns across (`$this->chargeCustomerService = $chargeCustomerService;`). Measured on the 48.5k-node Laravel corpus, `BalanceCustomerAccountService::handle()` minted no `calls` edge to `ChargeCustomerService::handle()` while its two sibling callers — one using a promoted param, one a typed method param — resolved fine. The **parameter's signature is the evidence and `@var` docblocks are still not read**: a docblock-only property with no constructor assignment types nothing, and where docblock and signature disagree the signature wins. The refusal discipline is the local-variable one: the property is poisoned rather than guessed at when its binding is not provably single-typed — a second assignment of a different or untypable type (a container resolve, an inline `new`, a local, a literal), an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body, including by a shadowing closure parameter. Because a property is not a local, that refusal is judged over the whole **class**, not just the constructor: a write to the property from any other method — a setter, a `boot()`, a closure captured anywhere — refuses the constructor's binding, since it is a deferred write that may run never, later, or repeatedly, and an *untyped* property (unlike a natively typed one, which PHP fatals on an incompatible write) carries no runtime guarantee that would make it safe. The refusal is unconditional on the write and not conditional on the written type, so even a same-typed setter costs the edge — deliberate, and the trade this discipline makes everywhere else too. A real annotation always outranks the assignment, so a natively typed or promoted property keeps its declared type and a union-typed one keeps its multi-class refusal (#9); every downstream refusal — interface/enum/trait receivers (#5, #12), the `use`-claimed out-of-corpus name (#16), the single-definition guard — applies unchanged, since this only fills in the receiver type the resolver was already asking for. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to pick the new edges up. + +**Resolution gaps closed on top of the above:** + +- Fix: a PHP **function call never binds to a method** across files (`lawnstarter/graphify#52`). In PHP a bare `name(...)` — a `function_call_expression` — can only invoke a global or namespaced *function*; reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, each of which takes a different path through the extractor. The shared cross-file pass matched by normalized label, and that normalization (`raw.strip("()").lstrip(".")`) erases the member marker the engine writes — a method labeled `.event()` and a function labeled `event()` both key as `event` — so Laravel's `event(...)` helper bound to whichever class happened to declare an `event()` method. On the pinned 46,406-node api.lawnstarter.com corpus that is **848 fabricated inbound `calls` edges on `GetProviderBillingEventsTest::event()`**, a test method credited as the callee of 368 distinct source files, 308 of them not tests. It was not even an ambiguity the god-node tie-breakers could catch: the three `Event` *classes* key as `Event`, so the method was the **sole** exact-case candidate and bound through the single-candidate path at INFERRED 0.8, before any tie-break could apply. The refusal is language-semantic rather than heuristic — a method is simply not a candidate at a function-call site — so it is applied to the candidate **list**, not by re-keying the indexes, which covers every consumer at once: the single-candidate bind, the symbol/module import-evidence disambiguation, and the god-node tie-break. The same language-semantic argument extends past methods: a **class-like declaration is not invocable either** — `report($e)` where only `class Report` exists is a "Call to undefined function" fatal, not a constructor call — so class, interface, enum and trait nodes are refused at a function-call site alongside methods, leaving only plausible function targets. That half also fixes a pre-existing bind this release would otherwise have inherited: `foo(...)` matching a cross-file `class Foo` through the case-insensitive fold has always produced a `calls` edge into the class. Both index paths are covered, and the folded lookup is *retried* after the method/class refusal rather than skipped: the case-insensitive fallback only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise have hidden a real `function Event()` reachable solely under the folded key, and the site would have resolved to nothing at all. The retry has to carry the refusal with it, or it hands the folded index a candidate the exact-case path never offered — a capitalized class under the shadowed lowercase key, which is exactly the **7 `report(...)` → `class Checkr\Resources\Report` edges** measured on the rebuilt 46.6k-node corpus. The class discriminator is the `_callable_class` marker the `indirect_call` guard already reads, chosen for the same reason as the label one: it is replayed onto `resolution_context_nodes` by both `graphify update` and `watch`, so it still classifies unchanged-corpus candidates on an incremental rebuild. The call site is identified by a marker the PHP extractor stamps on the raw call, which is load-bearing rather than convenience: a `scoped_call_expression` is also not a member call, so refusing on "PHP and not a member call" alone would have silently widened the policy to `Class::method()`. The method-vs-function discriminator is the engine's own label convention (`.name()` for a member, `name()` for a top-level function), chosen because a label persists into `graph.json` and so still classifies the unchanged-corpus candidates an incremental rebuild hands back as resolution context (#2406) — an edge-derived test would not, since the shared pass never receives those edges. PHP-only by construction: a Ruby or Python bare call reaches a method through implicit self, so their candidate filtering is deliberately untouched, as is the member-call resolver (`_resolve_php_member_calls`). One residual is documented rather than chased: a `event(...)` call in the *same file* as a class declaring an `event()` method still binds to it, because the extractor resolves that raw call in-file and it never reaches the cross-file pass — unchanged behavior, and the reason the measured fan-in shrinks to near-zero rather than provably zero. Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to shed these edges, and re-extraction genuinely requires the release's version bump to roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`): entries are keyed by content hash *within* that namespace, so a same-version rebuild replays the pre-fix raw calls untouched. +- Fix: `graphify query` and `graphify path` / `shortest_path` no longer resolve an endpoint to a **sourceless stub** when a real declaration carries the same label (`lawnstarter/graphify#54`, closing the gap `#49` left behind). `#49` taught `_find_node_tiers` and `affected`'s `resolve_seed` to drop stubs from a mixed exact tier, so `explain` and `affected` agree on the sourced declaration — but the scored path those two commands do not share, the `_score_nodes` ranking that feeds `_pick_scored_endpoint` (path) and `_pick_seeds` (query), never learned the rule. On the two-node repro — `FooRepository` at `source_file: ""` beside `FooRepository` at `app/bindings.php` — both nodes score **5619.081861425897**, identically, so the ranking fell through to its lexicographic node-id tie-break and answered with whichever id sorted first: `explain` named the declaration while `query` traversed the stub, reinstating for `explain`-vs-`query` exactly the divergence `#49` closed for `explain`-vs-`affected`, and violating the design intent the scorer's own comment states — that `path` and `query` resolve the same node `explain` does. The stub is rarely a merely cosmetic mismatch, because it is usually the *disconnected* node: `shortest_path` answered a reachable query with a false "No directed path found", and `query` spent its traversal on the stub's neighborhood instead of the declaration's. The fix is a **tie-break, not a scoring penalty**, and the distinction is load-bearing: a sourceless node can never *out*-score an otherwise-identical sourced one, since the only source-derived scoring term is additive and source hits do not count toward term coverage, so a shadowing stub always arrives at the sort as an exact tie and a tie-break is the complete and minimal intervention. The sourced preference is inserted directly under the score in both tie-break keys — the combined ranking sort and the per-term singleton-winner key that feeds the seed guarantee — above the label-length and node-id keys, which are arbitrary with respect to which node is real. No live score changes, so every score-sensitive baseline holds unedited. `#49`'s carve-out survives by construction rather than by a special case: an all-sourceless field has nothing to prefer, so a lone stub still wins its query and nothing that answered before now returns nothing. One asymmetry with `_find_node` is deliberate and left in place, since `_find_node` is unchanged here: it demotes stubs in its *exact* tier only, while a score tie can also arise from the prefix and substring tiers — on those shapes the scored path now prefers the sourced node where `_find_node` still answers by graph-iteration order, which is the more deterministic of the two answers, and closing the remaining gap would mean changing `_find_node` itself. Serve-side only: nothing in extraction, merge or the graph format changes, so an existing graph picks this up with **no rebuild and no re-extraction**. +- Fix: a PHP receiver typed with an `interface`, `enum` or `trait` name now binds to that declaration's own method instead of being refused outright (`lawnstarter/graphify#53`). `_resolve_php_member_calls` consulted a corpus-wide pre-scan of the non-class declaration names (`_php_non_class_types`) and skipped every such receiver — correct while the three minted no definition node, because the only thing such a receiver could land on was a same-short-named CLASS: Laravel's `App\Contracts\Notifier` interface beside an unrelated `App\Support\Notifier`, or `App\Enums\Status` beside an Eloquent `App\Models\Status`, each leaving exactly ONE definition under the short name and so satisfying the single-definition guard with the wrong answer. `#47` (in the node-identity group above) gave all three kinds canonical sourced nodes that their own methods attach to, which makes the refusal redundant: the collision now presents TWO definitions and is refused by the guard unaided, while a `use`-imported name is decided by the declared-FQN match. What the refusal was still costing is the rest — an interface or enum named by exactly one declaration, and a `use`-claimed one whose import says which of the namesakes it means — so `$this->notifier->send()` on a `private Notifier $notifier` now lands on `Notifier::send()` itself. Implementations are still never guessed: a `MailNotifier implements Notifier` gets nothing from an interface-typed receiver, and the same-short-named stranger class gets nothing on any path. +- The lift required extending the declared-FQN pre-scan to the three kinds, and that half is a fix in its own right: `_php_pre_scan_class_namespaces` recorded `class_declaration` only, so an interface/enum/trait node NEVER carried a declared FQN — not on a full build, not through the `_php_class_fqns` marker (`#23`). `PhpNameResolver`'s `use`-claim guard then fell back to comparing the node's PSR-4 PATH, and `_php_fqn_names_another_class` treats a path with FEWER segments than the written name as *no evidence* and keeps the edge. On a full build the paths are still absolute at resolver time, so the comparison ran and refused; on an incremental rebuild the replayed context node carries the relativized `app/Contracts/Notifier.php` (3 segments), so `use Illuminate\Contracts\Notifications\Notifier;` (4) tripped the bail-out and bound the in-corpus interface that the vendor import provably does not name — a wrong edge of the `#16` class, appearing ONLY under `graphify update`/`watch`, which is the normal operating mode. `Illuminate\Contracts\\` is exactly 4 segments and Laravel apps routinely declare `App\Contracts\{Repository,Factory,Guard,Mailer,…}` beside imports of the framework contracts with those same short names, so the shape is ordinary rather than exotic. All four declaration kinds are now read into `php_class_fqns` / `_php_class_fqns` / `fqn_def_nid`, which makes the guard whole-name and decisive on both build paths and closes the full-vs-incremental parity hole; the `tests/test_php_member_calls.py` cases assert the two builds' verdicts TOGETHER, since a full-build-only assertion is green on the broken code. +- The `_php_non_class_types` / `_php_interfaces` channel is kept intact — pre-scan, per-file payload, file-node stamp, `graphify update`/`watch` replay, both spellings read back — so graphs written by earlier versions still round-trip. Nothing consumes it for resolution any more; it remains the only record of declaration KIND that survives into a graph. +- Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to pick this up: the new edges come from the receiver-typing pass and the declared FQNs come from a pre-scan, and AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild replays the pre-fix payloads untouched. +- The package version bump to 0.9.40 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`). Most of the changes above are extraction-side, so a PHP corpus — and any polyglot corpus — must be re-extracted (`graphify update .`) to pick them up: AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild would replay the pre-fix facts untouched. The serve-side changes (the seeding group and the sourced-endpoint tie-breaks) need no rebuild. + +## 0.9.39 (unreleased) - Fix: `affected` now traverses a dynamic `import('…')` made inside a function or at module scope (#2584, thanks @phudayyy). The 0.9.38 dedupe keyed only on the target, so an in-function dynamic import (whose symbol-level edge is anchored on the enclosing function) suppressed the file-level edge `affected` follows; the dedupe now keys on the importing file, emitting one file-level `dynamic_import` edge per file/target while keeping the call-site edge. - Fix: a Python member call on an untyped receiver (`x.get(...)`) no longer binds by name alone to a same-named module-level function, fabricating a false high-confidence `calls` edge and a god node (#2417, #2586, thanks @EZZEASY). Such a call is now resolved only with receiver-type, import, or `self`/`cls`/`super` evidence, matching the TypeScript fix from 0.9.37; `super().method()` still resolves. @@ -93,12 +60,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify watch` now rebuilds on a documentation-only deletion batch instead of only flagging it (#2580, thanks @angmeng), so a deleted doc's nodes are evicted immediately rather than waiting for the next code-file event. (The general deleted-file leak was already fixed in 0.9.10; this closes the live-watcher residual.) - Fix: Objective-C member-call resolution (#2589, #2590, #2591, thanks @xiongjianxu). A `@protocol` declaration is no longer treated as a receiver type (it collided with a same-named class); a category or class-extension interface (`@interface Foo (Bar)`) now folds into the base class instead of minting a duplicate node; and a message send to a `@property` or ivar receiver (`[self.svc run]`, `[_svc run]`) now resolves through the property/ivar's declared type. -## 0.9.38 (upstream, 2026-08-09) - -> Number collision with the fork's own `0.9.38` section above, same artifact. -> This is upstream's 0.9.38, absorbed into the fork in 0.9.41 above; the -> fork's own 0.9.38 is the stub-shadowing and call-edge release -> (`lawnstarter/graphify#46`/`#47`/`#48`/`#49`/`#38`/`#39`). +## 0.9.38 (2026-08-09) - Fix: the 0.9.37 callback-body fix (#2552) no longer lets a local declared in one callback suppress a call in a sibling callback (#2568, thanks @imagineers-tyler). Each callback body's local names are now scoped to that body instead of unioned under the shared declaration, so a real `indirect_call` in one sibling closure is no longer dropped because another sibling declared a same-named local. This can only restore dropped edges, never fabricate. - Fix: Kotlin calls in a property initializer are now collected (#2565, thanks @kskchaitanya1993). A class property (`val repo = createRepo()`), a `by lazy { ... }` delegate, a companion-object property, and a top-level property initializer now produce `calls` edges attributed to the enclosing class (or file), including fully-qualified calls. A plain literal initializer produces no edge. @@ -106,13 +68,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the SQL extractor no longer emits a `reads_from` edge to a CTE name (#2577, thanks @wilyan09007). A `WITH cte AS (...)` name is scoped to its query and is no longer treated as a table, so it no longer mints a bare stub that could bind to an unrelated same-named symbol; an outer real table sharing a subquery-CTE's name still resolves. - Fix: a dynamic `await import('…')` inside a nested function or at module scope now produces an edge (#2575, thanks @phudayyy), and `dynamic_import` edges are now included in `affected`. Calls inside a nested named function are also collected now. A dynamic import already captured as a deferred `imports_from` is not double-counted. -## 0.9.37 (upstream, 2026-08-08) - -> Number collision with the fork's own `0.9.37` section above, the same -> artifact as the `0.9.35` one: the fork numbers its line while upstream -> numbers theirs. This is upstream's 0.9.37, absorbed into the fork in -> 0.9.40 above; the fork's 0.9.37 is the sync of upstream's 0.9.35. -> Dated 2026-08-08 by upstream's own 0.9.38 release, absorbed in 0.9.41. +## 0.9.37 (2026-08-08) - Fix: TypeScript member calls no longer fabricate a high-confidence `calls` edge by matching a receiver type by name alone (#2553, thanks @Earthfreedom). A member call now resolves only when the receiver's type is defined in the same file or actually imported by the caller's file, so a third-party `import type { Repo }` can no longer bind to an unrelated local `class Repo`; table-inferred receivers are tiered to INFERRED rather than EXTRACTED. - Fix: TypeScript/JavaScript calls inside a callback body passed to another call (for example `export const handler = wrapper(async (req) => { helper() })`) are no longer dropped (#2552, thanks @Earthfreedom). The callback body is now walked and its calls attributed to the declaration, through the same import-gated resolution so it cannot fabricate edges. @@ -120,10 +76,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify update` now retries a file whose extractor failed on a previous run instead of stamping it up-to-date forever (#2543, thanks @michaelxer). A failed extraction is no longer recorded in the manifest as processed, a manifest already poisoned by the old behavior is healed on the next run, and the fix avoids re-processing a genuinely-unchanged file. - Fix: the claude-cli backend now surfaces an API error carried in the stdout envelope (for example a rate limit returned with a zero exit code) instead of treating it as an empty success (#2554, thanks @annieyii). The error is raised on both the zero and non-zero exit paths. -## 0.9.36 (upstream, 2026-08-07) - -> Number collision with the fork's own `0.9.36` section above — same artifact. -> This is upstream's released 0.9.36, absorbed into the fork in 0.9.40 above. +## 0.9.36 (2026-08-07) - Fix: four commands that failed silently while exiting 0 now surface the problem (#2534, thanks @elecnix). `cluster-only` warns when `--backend`/`--model`/`--batch-size` are ignored because saved labels are being reused; the community-label prompt no longer collides with the discard sentinel (a model echoing the key back is no longer silently dropped); `tree --root` exits non-zero when the root matches no source file instead of silently flattening the tree; and `cluster-only` stamps `built_at_commit` from the analysed graph rather than the shell's working directory. Also folds in the `cluster-only` refused-write guard from #2522 (thanks @aniJani). - Fix: a Swift `extension Foo` in a different file from `Foo` no longer drops static and singleton call edges into the type (#2538, thanks @pawelo446). The extension node id is now remapped consistently so the extension merges onto its base type before call resolution, and the merge is gated so it never absorbs a same-named type from another language. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3fa25756b3..e6473a08c2 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -50,7 +50,7 @@ EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric 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. +**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, 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 receiver typed by an `interface`, `enum` or `trait` binds to that declaration's own method only; implementations are never guessed — `MailNotifier implements Notifier` gets nothing from a `Notifier`-typed receiver. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one. A PHP 8.1 first-class callable — `$obj->method(...)`, including the nullsafe and `$this` forms — resolves by exactly those rules but is emitted as **`indirect_call`**, not `calls`: the syntax creates a `Closure` and names the method without invoking it, the same shape the resolver already labels `indirect_call` for a callback passed by name. `calls` therefore keeps meaning "control flow transfers here". The spread form `$obj->method(...$args)` is a real invocation and stays `calls`. diff --git a/pyproject.toml b/pyproject.toml index 3e1d288064..49302298be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.41" +version = "0.9.40" 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"