diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d264a115..dd5908bd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 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. - Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped. - Fix: `graphify path` (and the MCP `shortest_path` tool) now respect edge direction by default instead of running on an undirected view, so a returned path no longer traverses edges backwards (#2487, thanks @luliaz0601). Direction is recovered from the stored `_src`/`_tgt` markers. Pass `--undirected` (CLI) or `undirected=true` (MCP) to search ignoring direction; when no directed path exists the command says so instead of silently returning a reversed one. - Fix: semantic extraction no longer aborts at merge with a `TypeError` when a hyperedge carries dict-shaped members (#2486, thanks @adminwat). Members are normalized to ids (or dropped with a warning) so a malformed hyperedge can no longer destroy a completed extraction. 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/graphify/cli.py b/graphify/cli.py index 534bed6b02..caa5268bbe 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3214,7 +3214,14 @@ def _ctx_identity(source_file) -> str | None: "file_type": _node.get("file_type"), "type": _node.get("type"), } - for _marker in ("_callable", "_callable_class"): + # `_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 dc7540d5fa..447ae4021c 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"}), @@ -2312,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") @@ -2473,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") @@ -2557,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") @@ -3025,6 +3043,251 @@ 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, + 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") + + +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. + + ``_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 + 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 marker in _PHP_NON_CLASS_TYPE_MARKERS + for name in (node.get(marker) or ()) + }) + return {"php_non_class_types": names} if names else None + + +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} + + # 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 ( + str(node.get("source_file") or "").lower().endswith(_PHP_RESOLVER_SUFFIXES) + 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) + + # 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 + # 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", []): + 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 + 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, + }) + + def _resolve_objc_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -3060,11 +3323,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] = {} @@ -3166,7 +3434,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, ) ) @@ -3178,6 +3446,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_RESOLVER_SUFFIXES), + _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, @@ -4724,9 +5001,12 @@ 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, + 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 unchanged corpus (#2437). The member-call resolvers walk these to map a receiver type to the single class owning the called method; @@ -5733,11 +6013,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/#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 []) + _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: @@ -5877,6 +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_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 c1466eff14..ece300c1dc 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -143,6 +143,101 @@ 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_NON_CLASS_DECLARATIONS = frozenset({ + "interface_declaration", + "enum_declaration", + "trait_declaration", +}) + + +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 + `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 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) + if text: + out.add(text) + stack.extend(n.children) + 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: @@ -609,6 +704,189 @@ 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 + + +# 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, + 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. + + 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, 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] = {} + 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 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": + 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): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False @@ -2431,11 +2709,21 @@ 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": 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() if config.ts_module == "tree_sitter_swift": @@ -3264,6 +3552,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: @@ -3595,6 +3895,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: @@ -3815,6 +4124,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 { … }`, @@ -4052,6 +4363,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: @@ -4246,6 +4565,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": @@ -4367,11 +4691,55 @@ 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() -> "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. + 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 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 @@ -4501,9 +4869,26 @@ 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": + 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 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 @@ -4568,6 +4953,15 @@ 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 + 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 @@ -4803,10 +5197,13 @@ 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 (flat) and C# - # (scoped, #2472) per-method receiver tables merge without collision — the - # stamp site branches on language to read the matching shape. - receiver_types_by_body = {**java_receiver_types, **csharp_receiver_types} + # Body ids are unique (one language per file), so the Java (flat), PHP + # (flat) and C# (scoped, #2472) per-method receiver tables merge without + # collision — the stamp site branches on language to read the matching + # shape. + 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, @@ -4925,6 +5322,34 @@ def _scan_js_module_dispatch(n) -> None: n["_callable_class"] = True 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. + 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. + 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. + 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/graphify/watch.py b/graphify/watch.py index 862997a682..043a75fc71 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1212,7 +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. - for marker in ("_callable", "_callable_class"): + # `_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_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py new file mode 100644 index 0000000000..3fb09f3084 --- /dev/null +++ b/tests/test_mixed_corpus_member_calls.py @@ -0,0 +1,223 @@ +"""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" + + +# ── 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 diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py new file mode 100644 index 0000000000..a8ef926fc3 --- /dev/null +++ b/tests/test_php_member_calls.py @@ -0,0 +1,1878 @@ +"""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": ( + "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 + + +# 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`.""" + 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" + + +# ── 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_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.""" + 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) + + +# ── 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 + + +# ── 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 + + +# ── 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_non_class_types", + "_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 + + +# 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 4854a6773c..32f770afb0 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3153,3 +3153,209 @@ 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( + "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" 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'" },