diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d264a115..e5fe393adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,25 @@ 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 was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. +- Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. +- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. +- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. +- Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. +- Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). +- The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. - Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped. - 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. - Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots. - Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly. - Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz). +- PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label. ## 0.9.33 (2026-08-05) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index e0e6e5275d..3fa25756b3 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -48,6 +48,12 @@ 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. + +A PHP 8.1 first-class callable — `$obj->method(...)`, including the nullsafe and `$this` forms — resolves by exactly those rules but is emitted as **`indirect_call`**, not `calls`: the syntax creates a `Closure` and names the method without invoking it, the same shape the resolver already labels `indirect_call` for a callback passed by name. `calls` therefore keeps meaning "control flow transfers here". The spread form `$obj->method(...$args)` is a real invocation and stays `calls`. + --- ## Token benchmark diff --git a/graphify/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..2f37bad31b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -47,6 +47,10 @@ from graphify.extractors.json_config import extract_json # noqa: F401 from graphify.extractors.markdown import extract_markdown # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 +from graphify.extractors.php import ( # noqa: F401 + PhpNameResolver, + _php_qualified_corroborates, +) from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 @@ -106,6 +110,8 @@ _pascal_resolve_class, _pascal_resolve_unit, _pascal_unit_cache, + _php_use_clause_context, + _php_use_clause_fact, _pnpm_workspace_globs, _python_call_identifier, _python_import_from_module, @@ -662,23 +668,32 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: - for child in node.children: - if child.type in ("qualified_name", "name", "identifier"): - raw = _read_text(child, source) - module_name = raw.split("\\")[-1].strip() - if module_name: - tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) - break + # `node` is a single `namespace_use_clause`; the group-use prefix and the + # `function`/`const` keyword of a group use live on the parent declaration, + # so the clause alone cannot spell its own FQN. Shared parser with + # `_resolve_php_type_references` — see resolution.py. + fact = _php_use_clause_fact(node, source, *_php_use_clause_context(node, source)) + if fact is None: + return + target_fqn, alias, use_kind = fact + # The edge target stays keyed on the imported short name: re-pointing it is + # the resolvers' job (`_resolve_php_type_references`), not the capture's. + module_name = target_fqn.rsplit("\\", 1)[-1].strip() + if not module_name: + return + edges.append({ + "source": file_nid, + "target": _make_id(module_name), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "metadata": sanitize_metadata({k: v for k, v in + {"use_kind": use_kind, "alias": alias, "target_fqn": target_fqn}.items() + if v is not None}), + }) # ── C/C++ function name helpers ─────────────────────────────────────────────── @@ -924,7 +939,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 +2330,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 +2496,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 +2585,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 +3058,238 @@ 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") + + +_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. + + A raw call marked ``fcc`` is a PHP 8.1 first-class callable + (``$obj->method(...)``): the method is referenced, not invoked, so it + resolves by exactly the rules above but is emitted as ``indirect_call`` + (#15). + """ + def key(label: str) -> str: + # PHP class and method names are case-insensitive. + 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", {}) + + # `use`-import/namespace-aware receiver typing (#21), consulted IN FRONT of + # the corpus-wide short-name index below — the shape of the C# call site in + # `_resolve_csharp_member_calls`. A name the calling file CLAIMS through a + # `use` import or writes out qualified is decided here and never falls back: + # that refusal is the whole fix for #16. It can only delete edges, never add + # or re-point one — see PhpNameResolver. + resolver = PhpNameResolver(all_nodes, all_edges, type_def_nids, class_fqn_by_file) + + def declared_fqn(type_node: dict | None) -> str | None: + """The namespace-qualified name of ``type_node``'s class, if its file + declared one. Absent for a global-namespace class, and for a node + 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} + # First-class callables (`fcc`, #15) go last: the sort is stable, so ordinary + # invocations keep source order and claim the (caller, method) pair first. A + # real call outranks a mere reference to the same method, which is also the + # precedence the in-file and cross-file indirect-dispatch passes use. + php_raw_calls = [ + raw_call + for result in per_file + for raw_call in result.get("raw_calls", []) + if raw_call.get("lang") == "php" and raw_call.get("is_member_call") + ] + php_raw_calls.sort(key=lambda raw_call: bool(raw_call.get("fcc"))) + for raw_call in php_raw_calls: + receiver = raw_call.get("receiver") + callee = raw_call.get("callee") + caller = raw_call.get("caller_nid") + if not receiver or not callee or not caller: + continue + + if receiver == "this": + exact = True + type_nid = enclosing_type.get(caller) + if not type_nid: + continue + 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 + resolved, decisive = resolver.resolve_type_name( + type_name, + raw_call.get("receiver_type_qualified"), + raw_call.get("source_file", ""), + ) + if resolved: + type_nid = resolved + elif decisive: + continue # the name is claimed and names no in-corpus class (#16) + else: + type_defs = type_def_nids.get(key(type_name), []) + if len(type_defs) != 1: + continue # short name collides across the corpus: refuse + type_nid = type_defs[0] + if receiver == "(new)": + # The class is named in the source; promote to EXTRACTED + # only when the written namespace backs the node we found. + 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, + # A first-class callable NAMES the method without invoking it, so it + # is the repo's `indirect_call`, not `calls` (#15). Everything else + # above — receiver typing, the single-definition and interface/enum/ + # trait refusals, the confidence ladder — is deliberately shared. + "relation": "indirect_call" if raw_call.get("fcc") else "calls", + "context": "call", + "confidence": "EXTRACTED" if exact else "INFERRED", + "confidence_score": 1.0 if exact else 0.8, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + }) + + def _resolve_objc_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -3060,11 +3325,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 +3436,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 +3448,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 +5003,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 +6015,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 +6170,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..6ea3f4b431 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -9,6 +9,7 @@ from graphify.extractors.resolution import _resolve_js_import_target from graphify.security import sanitize_metadata from pathlib import Path +from typing import NamedTuple def _csharp_namespace_id(dotted_name: str) -> str: @@ -143,6 +144,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 +705,264 @@ 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", +}) + + +class _PhpReceiverType(NamedTuple): + """A receiver's declared type: the short name plus the WRITTEN form (#20). + + `short` is what the resolver has always matched on — namespace-stripped, so + `\\Vendor\\Sdk\\Client` and a bare `Client` are the same key. `qualified` is + the annotation exactly as written, and ONLY when it carries a namespace + separator: an unqualified annotation leaves it None, so its facts stay + identical to the pre-#20 ones. It is independent evidence about WHICH + same-short-named class was meant — the fact the use-import resolver (#21) + needs and the flattening in `_php_name_text` used to destroy. + """ + short: str + qualified: str | None + + +def _php_written_type(name_node, source: bytes) -> _PhpReceiverType | None: + """Pair a PHP `name`/`qualified_name` node's short name with its written text.""" + short = _php_name_text(name_node, source) + if not short or short.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: + return None + written = _read_text(name_node, source) + return _PhpReceiverType(short, written if "\\" in written else None) + + +def _php_concrete_type(type_node, source: bytes) -> _PhpReceiverType | None: + """Single concrete class named by a PHP type expression, or None (= refuse). + + Deliberately NOT `_php_collect_type_refs`: that helper flattens a union into + several refs, whereas a receiver typed `A|B` has no single type and must be + refused. `named_type` yields its name (short + written, #20); a nullable + wrapper around exactly one type unwraps (`?Foo` is still concretely Foo); + union, intersection, primitive and missing types yield None (#1682). + """ + if type_node is None: + return None + if type_node.type == "named_type": + for c in type_node.children: + if c.type in ("name", "qualified_name"): + return _php_written_type(c, source) + return None + if type_node.type in ("optional_type", "nullable_type"): + inner = [c for c in type_node.named_children if c.type != "comment"] + if len(inner) == 1: + return _php_concrete_type(inner[0], source) + return None + + +# Type expressions that declare MORE THAN ONE possible class for a receiver: +# union (`A|B`), intersection (`A&B`) and PHP 8.2 disjunctive-normal-form +# (`(A&B)|C`, a union at top level) — node names probe-verified against +# tree-sitter-php 0.24.1. +_PHP_MULTI_TYPE_NODES = frozenset({ + "union_type", "intersection_type", "disjunctive_normal_form_type", +}) + + +def _php_multi_typed_annotation(type_node) -> bool: + """True when a PHP type annotation names more than one candidate class (#9). + + `_php_concrete_type` refuses several shapes, and the two reasons for + refusal must be told apart at the call site (user story 11): a receiver whose + annotation is a UNION or INTERSECTION provably has several possible classes, + so an in-file bare-name match can only pick one of them by file order and + must be suppressed. The policy's other refusals — `self`/`static`/`parent`, + primitives, `mixed`/`object`/`iterable`/`callable`, a nullable wrapping more + than one type — declare no such multiplicity, so they deliberately keep + today's in-file edge, exactly like a genuinely untyped receiver (#2's + accepted deviation, user story 9). `self` especially: it names the calling + class, whose methods usually ARE the in-file match. + """ + return type_node is not None and type_node.type in _PHP_MULTI_TYPE_NODES + + +# Subtrees that are a DIFFERENT binding scope than the method being scanned: +# their assignments must not type the enclosing method's variables. Closures are +# deliberately absent — their calls are attributed to the enclosing method, so +# 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, _PhpReceiverType | None], +) -> dict[str, _PhpReceiverType | None]: + """Build the receiver type table visible to one PHP method (#1682). + + ``this.`` keys come from the declaring class's typed properties and + 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. + + A key mapped to None is PRESENT but unresolved: its annotation named several + candidate classes (`A|B`, `A&B`), which the call site must tell apart from an + ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > + multi-class refusal > absent, so a union-typed param later assigned a `new T()` + still resolves to T, while a poisoned union-typed one stays refused. + + A resolved value carries both the short name and the written qualified form + (#20); every decision below is taken on the SHORT name alone, so the table's + keys and their resolved/refused/absent status are exactly what they were. + """ + table: dict[str, _PhpReceiverType | None] = { + f"this.{name}": type_name for name, type_name in field_types.items() + } + method_types: dict[str, _PhpReceiverType] = {} + multi_typed_params: set[str] = set() + ambiguous: set[str] = set() + + def poison(name: str) -> None: + if name: + method_types.pop(name, None) + ambiguous.add(name) + + def bind(name: str, declared: _PhpReceiverType | None) -> None: + if not name or name in ambiguous: + return + previous = method_types.get(name) + if declared is None or (previous is not None and previous.short != declared.short): + poison(name) + elif previous is not None and previous.qualified != declared.qualified: + # Same short name written two ways (`new Client()` then + # `new \Vendor\Client()`): the SHORT binding is what it always was, + # so keep it. Only the qualified evidence conflicts — drop that + # rather than poison a name today's table still types (#20). + method_types[name] = _PhpReceiverType(previous.short, None) + else: + method_types[name] = declared + + 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) -> _PhpReceiverType | None: + """Class named by an ``object_creation_expression``, or None. + + `new self()` / `new static()` need inheritance context the raw-call + facts do not carry, so the non-concrete set refuses them. + """ + if node is None or node.type != "object_creation_expression": + return None + cls = next((c for c in node.named_children + if c.type in ("name", "qualified_name")), None) + if cls is None: # `new class { … }` names nothing + return None + return _php_written_type(cls, source) + + # Natively typed parameters. `variadic_parameter` is excluded on purpose: + # `T ...$xs` binds an ARRAY of T, not a T. + 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_node = param.child_by_field_name("type") + declared = _php_concrete_type(type_node, source) + name_node = param.child_by_field_name("name") + if name_node is not None and declared: + # Untyped / primitive params simply stay unbound. + bind(_read_text(name_node, source).lstrip("$"), declared) + elif name_node is not None and _php_multi_typed_annotation(type_node): + # A union/intersection param is not BOUND — `bind(None)` would + # poison it, and poisoning is indistinguishable from untyped. + # Mark it instead, and let the merge below apply precedence. + multi_typed_params.add(_read_text(name_node, source).lstrip("$")) + + body = method_node.child_by_field_name("body") + stack = list(body.children) if body is not None else [] + 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) + for name in multi_typed_params: + # setdefault, so a concrete type learned from a `new` wins; a poisoned + # name (popped just above) falls back to the multi-class refusal. + table.setdefault(name, None) + return table + + def _php_method_return_type_node(method_node): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False @@ -2431,11 +2785,24 @@ 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. + # `prop -> declared type` per class. A value of None means the annotation was + # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call + # site must tell apart from an ABSENT key = no annotation at all (#9). + php_field_types: dict[str, dict[str, _PhpReceiverType | None]] = {} + 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": @@ -3261,9 +3628,30 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: and parent_class_nid): for c in node.children: if c.type not in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + # PHP 8.2 `(A&B)|C`, absent from this list until + # #9 and so invisible to both the receiver table + # and the type-reference walk below. + "disjunctive_normal_form_type"): continue line = node.start_point[0] + 1 + # #1682: remember the property's declared type so a later + # `$this->prop->method()` resolves against it. Only a single + # concrete class name counts — unions/primitives are refused. + # A multi-class annotation is recorded as None so the call site + # can defer rather than bare-name match it (#9); every other + # refusal leaves the property out of the table entirely. A + # resolved type keeps the written qualified form too (#20). + declared = _php_concrete_type(c, source) + multi_typed = _php_multi_typed_annotation(c) + if declared or multi_typed: + fields = php_field_types.setdefault(parent_class_nid, {}) + for pe in node.children: + if pe.type != "property_element": + continue + v = pe.child_by_field_name("name") + if v is not None: + fields[_read_text(v, source).lstrip("$")] = declared refs: list[tuple[str, str]] = [] _php_collect_type_refs(c, source, False, refs) for ref_name, role in refs: @@ -3592,9 +3980,22 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = None for sub in p.children: if sub.type in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + "disjunctive_normal_form_type"): type_node = sub break + # #1682: a promoted param IS a typed class property — + # record it in the same `this.` receiver table, + # multi-class annotations included as None (#9). + if is_promoted and parent_class_nid: + promoted_type = _php_concrete_type(type_node, source) + v = p.child_by_field_name("name") + if v is not None and ( + promoted_type or _php_multi_typed_annotation(type_node) + ): + 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 +4216,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 { … }`, @@ -4027,6 +4430,10 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: seen_call_pairs: set[tuple[str, str]] = set() seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup + # PHP first-class-callable indirect_call dedup (#15), kept apart from + # seen_call_pairs so a `$this->m(...)` reference can never swallow the real + # `$this->m()` call to the same target. + seen_php_fcc_pairs: set[tuple[str, str]] = set() seen_dyn_import_pairs: set[tuple[str, str]] = set() seen_static_ref_pairs: set[tuple[str, str, str]] = set() seen_helper_ref_pairs: set[tuple[str, str, str]] = set() @@ -4052,6 +4459,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: @@ -4201,9 +4616,13 @@ def _php_class_const_scope(n) -> str | None: def walk_calls( node, caller_nid: str, - # Java: flat name -> type. C#: the (scoped bindings, field base) pair - # from _csharp_method_receiver_types, resolved positionally (#2472). - receiver_types: dict[str, str] | tuple | None = None, + # Java values are plain short names; C# is the (scoped bindings, + # field base) pair from _csharp_method_receiver_types, resolved + # positionally (#2472); PHP values are a (short, qualified) pair and + # may be None — see _php_method_receiver_types. Body ids are + # language-disjoint, so one body's table only ever meets its own + # language's reader. + receiver_types: dict[str, str | _PhpReceiverType | None] | tuple | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -4246,6 +4665,15 @@ 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 + # PHP 8.1 first-class callable `$obj->method(...)` (#15): a + # reference to the method, not an invocation — re-tagged as + # `indirect_call` below and by the cross-file PHP resolver. + php_fcc: bool = False # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -4367,11 +4795,70 @@ 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) + # First-class callable `$obj->method(...)` (#15). PHP 8.1 + # reuses member_call_expression for it, so the shared + # `node.type in config.call_types` gate cannot tell it + # from an invocation — the argument list can. Probe- + # verified on the pinned grammar (tree-sitter-php 0.24.1): + # `m(...)` parses as `arguments: (arguments + # (variadic_placeholder))` — exactly one named child of + # that type. `m()`, `m(1)` and the spread `m(...$args)` + # (one `argument` child) do not, so the discriminator is + # unambiguous. + args_node = node.child_by_field_name("arguments") + if args_node is not None: + named_args = args_node.named_children + php_fcc = (len(named_args) == 1 + and named_args[0].type == "variadic_placeholder") elif config.ts_module == "tree_sitter_cpp": # C++: function field, then field_expression/qualified_identifier func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None @@ -4501,9 +4988,45 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - if _java_defer or ( + # PHP (#1682): defer when the receiver's type is actually known — + # a typed `$this->prop->m()` must not bare-match an unrelated + # same-named method in this file. Plain `$this->m()` and untyped + # receivers keep today's in-file match, since the resolver could + # add nothing for them anyway. + # + # ALSO defer when the annotation was PRESENT but named several + # candidate classes (`A|B`, `A&B`) — user story 11 (#9). Such a + # receiver stamps no type, so before this the refusal was + # indistinguishable from "untyped" and the in-file arm bound the + # call to whichever same-named method came last in the file, at + # EXTRACTED confidence. The receiver table encodes the difference: + # a PRESENT key mapped to None is a refused multi-class + # annotation, an ABSENT key is no annotation at all. + _php_receiver_type: str | None = None + # The annotation as WRITTEN, when it carried a namespace (#20). + # Stamped alongside the short name; nothing decides on it yet. + _php_receiver_qualified: str | None = None + _php_multi_typed_receiver = False + if config.ts_module == "tree_sitter_php": + if php_inline_new_type: + _php_receiver_type = php_inline_new_type + elif member_receiver and member_receiver != "this": + _php_types = receiver_types or {} + _php_declared = _php_types.get(member_receiver) + if _php_declared is not None: + _php_receiver_type = _php_declared.short + _php_receiver_qualified = _php_declared.qualified + else: + _php_multi_typed_receiver = member_receiver in _php_types + _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver + if _java_defer or _php_defer or ( is_member_call 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 @@ -4515,9 +5038,38 @@ def walk_calls( tgt_nid = label_to_nid.get(callee_name) if tgt_nid and tgt_nid != caller_nid: pair = (caller_nid, tgt_nid) - if pair not in seen_call_pairs: + if php_fcc: + # `$this->m(...)` names an in-file method: same target, + # same EXTRACTED confidence, distinct relation (#15). A + # direct call to the target already recorded wins the + # pair — the precedence _emit_indirect_ref already uses. + if (pair not in seen_call_pairs + and pair not in seen_php_fcc_pairs): + seen_php_fcc_pairs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "indirect_call", + "context": "call", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + elif pair not in seen_call_pairs: seen_call_pairs.add(pair) line = node.start_point[0] + 1 + if pair in seen_php_fcc_pairs: + # An earlier first-class-callable reference claimed + # this pair; the real invocation supersedes it, so + # the outcome does not depend on source order. + seen_php_fcc_pairs.discard(pair) + edges[:] = [ + e for e in edges + if not (e.get("relation") == "indirect_call" + and e.get("source") == caller_nid + and e.get("target") == tgt_nid) + ] edges.append({ "source": caller_nid, "target": tgt_nid, @@ -4568,6 +5120,26 @@ 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_fcc: + # Marker read by _resolve_php_member_calls, which + # emits `indirect_call` for it under the SAME + # target-resolution and refusal rules (#15). + rc_entry["fcc"] = True + if _php_receiver_type: + rc_entry["receiver_type"] = _php_receiver_type + if _php_receiver_qualified: + # Written form of the ANNOTATION that typed the + # receiver (#20) — kept apart from the inline-`new` + # `receiver_qualified` below, which is the class + # named at the call itself. + rc_entry["receiver_type_qualified"] = _php_receiver_qualified + if php_inline_new_qualified: + rc_entry["receiver_qualified"] = php_inline_new_qualified raw_calls.append(rc_entry) # Indirect dispatch: a function passed BY NAME as a call argument @@ -4803,10 +5375,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 +5500,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/extractors/php.py b/graphify/extractors/php.py new file mode 100644 index 0000000000..093b069d29 --- /dev/null +++ b/graphify/extractors/php.py @@ -0,0 +1,279 @@ +"""PHP cross-file resolution. + +The config-driven PHP *extractor* (``extract_php`` → ``_extract_generic``) still +lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the +config-driven languages cannot be ported one-by-one until the shared +``_extract_generic`` core moves as its own coordinated batch. This module is the +PHP home for the parts that *are* cleanly separable — today, the name-resolution +side of the member-call pass: matching a written class name against a definition +node, and the ``use``-import-aware receiver typing built on top of it. +""" +from __future__ import annotations + +from graphify.extractors.resolution import _php_fqn_from_raw + +# Mirrors `_PHP_RESOLVER_SUFFIXES` in graphify/extract.py. Kept local, like +# `_is_cs_file` in csharp.py, so this module imports nothing from the facade. +_PHP_SOURCE_SUFFIXES = ( + ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", +) + + +def _is_php_file(value: object) -> bool: + return isinstance(value, str) and value.lower().endswith(_PHP_SOURCE_SUFFIXES) + + +def _metadata(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _php_key(name: object) -> str: + """Fold a PHP class name: the language matches them case-insensitively.""" + return str(name).strip().casefold() + + +def _php_qualified_corroborates( + qualified: str | None, + type_node: dict | None, + declared_fqn: str | None = None, +) -> bool: + """True when a source-written class name corroborates the resolved node (#1682). + + ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name + lookup that found the node ignored the namespace — so the namespace is + independent evidence, and only a match makes the edge EXTRACTED. + + ``declared_fqn`` is the name the DEFINING FILE declares for that class, + read from its ``namespace`` statement at extraction time (#14). When it is + known the comparison is whole-name: `\\App\\Services\\Client` corroborates + `App\\Services\\Client` and nothing else. Two things that used to promote + now do not — a file whose declared namespace disagrees with its PSR-4 path + (the written name then denotes a class that exists nowhere in the corpus), + and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT + class from `App\\Services\\Client` but matched as a path tail. + + Without a declaration — the file declares no namespace at all, or the node + came from a prior graph on an incremental run — the node's path is the only + corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto + ``app/Services/Svc.php``; every written segment must line up with the tail + of that path, case-insensitively. + + A BARE name (no namespace segment) corroborates nothing and stays INFERRED; + a namespace that does not line up downgrades rather than refusing, since the + class name itself still resolved unambiguously. + """ + if not qualified or not type_node: + return False + want = [seg.casefold() for seg in str(qualified).split("\\") if seg] + if len(want) < 2: + return False # bare `new Svc()`: no namespace written, no evidence + if declared_fqn: + have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] + return want == have # whole name, not a suffix of one + source_file = str(type_node.get("source_file") or "") + parts = [p for p in source_file.replace("\\", "/").split("/") + if p and p not in (".", "..")] + if not parts: + return False + parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension + parts = [p.casefold() for p in parts] + return len(parts) >= len(want) and parts[-len(want):] == want + + +def _php_fqn_names_another_class( + fqn: str, + type_node: dict | None, + declared_fqn: str | None, +) -> bool: + """True when ``fqn`` provably names something OTHER than ``type_node`` (#21). + + The contrapositive of ``_php_qualified_corroborates``, with one deliberate + difference: *absent* evidence is not a contradiction. Otherwise the same + comparison the inline-`new` promotion already makes — the declared name when + the defining file was dispatched this run, its PSR-4 path when it was not. + + Two shapes carry no evidence either way and so keep the edge: + * a name with no namespace segment (`use Client;` imports from the global + namespace and writes nothing to compare); + * a path with fewer segments than the written name, once the declaration + is unavailable — composer maps a namespace PREFIX onto a directory + (`App\\Domain\\` -> `src/`), and a stripped prefix is indistinguishable + from a different class. Refusing there would delete true edges on + incremental rebuilds only, where the declaration is what is missing; + persisting it for unchanged files is #23. + """ + if type_node is None: + return False + want = [seg for seg in str(fqn).split("\\") if seg] + if len(want) < 2: + return False + if not declared_fqn: + parts = [ + part + for part in str(type_node.get("source_file") or "").replace("\\", "/").split("/") + if part and part not in (".", "..") + ] + if len(parts) < len(want): + return False + return not _php_qualified_corroborates(fqn, type_node, declared_fqn) + + +class PhpNameResolver: + """``use``-import/namespace-aware PHP receiver-type resolution (#21). + + The PHP twin of ``CsharpNameResolver`` (``extractors/csharp.py``), built for + the same reason: ``_resolve_php_member_calls`` bound a receiver's short type + name through a corpus-wide index whose only refusal rule was "more than one + candidate", so a file that writes ``use Vendor\\Sdk\\Client;`` — CLAIMING the + name ``Client`` for a class outside the corpus — still bound the lone + unrelated ``App\\Local\\Client`` and minted a wrong ``INFERRED 0.8`` edge + (#16). Consulted in front of that fallback, this resolver makes the claim + decisive: it refuses instead of guessing. + + Built from graph-stamped facts only — the ``imports`` edges' ``use`` + metadata (#19), the declared-FQN payload of the files dispatched this run + (#14), and the same type-definition index the fallback uses. Nothing is + re-parsed, and no new persisted marker is needed: the ``use`` map belongs to + the CALLING file, which an incremental rebuild always re-dispatches. + + STRICTLY SUBTRACTIVE by construction. Every node this returns is looked up + under the receiver's WRITTEN short name in the very index the fallback + consults, so a positive verdict is always the answer the fallback would have + given; the only behavior change is the refusal. Binding ``use App\\X as Y;`` + to a class the short name ``Y`` does not name, or picking the aliased one of + several same-short-named classes, is a recall ADDITION and belongs to #22. + """ + + def __init__( + self, + all_nodes: list[dict], + all_edges: list[dict], + type_def_nids: dict[str, list[str]], + class_fqn_by_file: dict[str, dict[str, str]] | None = None, + ) -> None: + self.type_def_nids = type_def_nids + self.class_fqn_by_file = class_fqn_by_file or {} + self.node_by_id: dict[str, dict] = { + node["id"]: node + for node in all_nodes + if isinstance(node.get("id"), str) and node.get("id") + } + + # Per file: claimed short name -> imported FQN. Read off the edge + # METADATA, never the target node's label — `_resolve_php_type_references` + # re-points import targets (onto an FQN stub, or onto the in-corpus class + # the unique-label rewire finds), and only the metadata still spells what + # the file actually wrote (#19). + self.uses_by_file: dict[str, dict[str, str]] = {} + for edge in all_edges: + if edge.get("relation") != "imports": + continue + source_file = edge.get("source_file") + if not _is_php_file(source_file): + continue + metadata = _metadata(edge.get("metadata")) + # `use function` / `use const` import no class name, in either + # spelling — the shared parser reports the declaration-level keyword + # of the group form too (#26). + if metadata.get("use_kind") != "class": + continue + target_fqn = metadata.get("target_fqn") + if not isinstance(target_fqn, str) or not target_fqn: + continue + alias = metadata.get("alias") + claimed = _php_key( + alias if isinstance(alias, str) and alias + else target_fqn.rsplit("\\", 1)[-1] + ) + if claimed: + # Two `use`s claiming one name is a PHP fatal error; first wins. + self.uses_by_file.setdefault(source_file, {}).setdefault( + claimed, target_fqn + ) + + # Per file: the namespace its declarations sit in, needed to resolve a + # namespace-RELATIVE annotation (`Local\Client`). Derived from the + # declared-FQN payload, which covers every file dispatched this run — + # and the file that writes the annotation always is one. A file that + # declares two namespaces (a PSR-1 violation) is left out rather than + # answered with one of them. + self.namespace_by_file: dict[str, str] = {} + for path, classes in self.class_fqn_by_file.items(): + namespaces = { + fqn.rsplit("\\", 1)[0] if "\\" in fqn else "" + for fqn in classes.values() + } + if len(namespaces) == 1: + self.namespace_by_file[path] = next(iter(namespaces)) + + def _declared_fqn(self, type_node: dict | None) -> str | None: + """The name the DEFINING file declares for ``type_node``'s class (#14). + + Absent for a global-namespace class, and for a class whose file was not + dispatched this run — an incremental rebuild then falls back to the + PSR-4 path comparison, which is why the refusal needs no new marker. + """ + if not type_node: + return None + by_name = self.class_fqn_by_file.get(str(type_node.get("source_file") or "")) + if not by_name: + return None + return by_name.get(_php_key(type_node.get("label", ""))) + + def _written_fqn(self, written: str, source_file: str) -> str | None: + """The FQN a QUALIFIED written annotation denotes, or None if unknowable. + + PHP resolves `\\A\\B` absolutely, `A\\B` through the `use` map's + group-prefix semantics and otherwise relative to the current namespace — + never against the global namespace. A leading backslash is therefore not + something to assume nor to strip blindly (#20). + """ + raw = written.strip() + if raw.startswith("\\"): + return raw.lstrip("\\") + uses = self.uses_by_file.get(source_file, {}) + if _php_key(raw.split("\\", 1)[0]) in uses: + return _php_fqn_from_raw(raw, "", uses) + namespace = self.namespace_by_file.get(source_file) + if namespace is not None: + return _php_fqn_from_raw(raw, namespace, uses) + if source_file in self.class_fqn_by_file: + return None # two namespaces in one file: refuse to pick one + return _php_fqn_from_raw(raw, "", uses) # the file is global-namespace + + def resolve_type_name( + self, type_name: str, qualified: object, source_file: str + ) -> tuple[str | None, bool]: + """Resolve a receiver's declared type to a definition node, with a verdict. + + Returns ``(node_id, decisive)``: + * ``(nid, True)`` — the file's own naming lands on that definition. + * ``(None, True)`` — the file CLAIMS the name (a `use` import, or a + qualified form written at the annotation) and the claim does not + land on an in-corpus class: refuse, and do NOT let the caller fall + back to the looser corpus-wide bare-name match. This is #16. + * ``(None, False)`` — nothing in the file claims the name; the + caller's existing fallback runs unchanged. + """ + short = _php_key(type_name) + if not short: + return None, False + written = qualified.strip() if isinstance(qualified, str) else "" + if "\\" in written: + fqn = self._written_fqn(written, source_file) + else: + fqn = self.uses_by_file.get(source_file, {}).get(short) + if not fqn: + return None, False + + candidates = self.type_def_nids.get(short, []) + if len(candidates) != 1: + # Nothing in the corpus answers to the written name, or several + # things do and the fallback would refuse too. Either way there is + # no edge to keep — telling the alias's target apart from its + # namesakes is #22's job, and this ticket may only delete. + return None, True + node = self.node_by_id.get(candidates[0]) + if _php_fqn_names_another_class(fqn, node, self._declared_fqn(node)): + return None, True + return candidates[0], True diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 097c32b6a7..f3c7800543 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2414,6 +2414,102 @@ def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: return f"{ns}\\{raw}" if ns else raw +# ── Shared PHP `use`-statement parser ───────────────────────────────────────── +# One parser for both consumers: the `uses_by_file` map below and the `imports` +# edge capture in `_import_php` (extract.py). Group use `use A\{B, C as X};`, +# aliases, leading-backslash absolutes and `use function` / `use const` are all +# handled here so neither consumer has to re-derive them. + +def _php_use_clause_fact( + clause, + source: bytes, + prefix: str = "", + kind: str = "class", +) -> tuple[str, str | None, str] | None: + """Parse one ``namespace_use_clause`` into ``(target_fqn, alias, use_kind)``. + + ``prefix`` is the group-use prefix (empty for a standalone clause) and + ``kind`` the declaration-level ``function``/``const`` keyword, if any; a + clause-level keyword overrides it. Returns ``None`` when the clause names + no target (e.g. a parse error). + """ + target: str | None = None + alias: str | None = None + saw_as = False + for c in clause.children: + if c.type in ("function", "const"): + kind = c.type + elif c.type == "as": + saw_as = True + elif c.type in ("qualified_name", "name"): + if saw_as: + alias = _read_text(c, source) + elif target is None: + target = _read_text(c, source) + if not target: + return None + fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") + return fqn, alias, kind + + +def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: + """``(group prefix, use kind)`` a ``namespace_use_clause`` inherits from its + parent ``namespace_use_declaration``. + + For consumers that are dispatched per clause and never see the declaration + (`_import_php`). The prefix only applies to clauses inside a + ``namespace_use_group``; a standalone clause carries its own full name. + """ + parent = getattr(clause, "parent", None) + in_group = parent is not None and parent.type == "namespace_use_group" + decl = parent.parent if in_group else parent + prefix, kind = "", "class" + if decl is None or decl.type != "namespace_use_declaration": + return prefix, kind + for c in decl.children: + if c.type == "namespace_name" and in_group: + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + return prefix, kind + + +def _php_use_declaration_facts( + decl, + source: bytes, +) -> list[tuple[str, str | None, str]]: + """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. + + ``use function A\\f;`` puts the keyword on the *clause*, while + ``use function A\\{f, g};`` puts it on the *declaration* — both spellings + yield ``use_kind == "function"`` here. + """ + prefix, kind, group = "", "class", None + direct = [] + for c in decl.children: + if c.type == "namespace_name": + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + elif c.type == "namespace_use_group": + group = c + elif c.type == "namespace_use_clause": + direct.append(c) + + facts: list[tuple[str, str | None, str]] = [] + for c in direct: + fact = _php_use_clause_fact(c, source, "", kind) + if fact: + facts.append(fact) + if group is not None: + for c in group.children: + if c.type == "namespace_use_clause": + fact = _php_use_clause_fact(c, source, prefix, kind) + if fact: + facts.append(fact) + return facts + + def _resolve_php_type_references( per_file: list[dict], paths: list[Path], @@ -2473,27 +2569,6 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: - target = None - alias = None - saw_as = False - for c in clause.children: - if c.type in ("function", "const"): - return # not a class import - if c.type == "as": - saw_as = True - elif c.type in ("qualified_name", "name"): - if saw_as: - alias = _read_text(c, source) - elif target is None: - target = _read_text(c, source) - if not target: - return - fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") - key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() - if key: - uses.setdefault(key, fqn) - def walk(n) -> None: t = n.type if t == "namespace_definition": @@ -2502,19 +2577,12 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - prefix = "" - group = None - for c in n.children: - if c.type == "namespace_name": - prefix = _read_text(c, source) # group-use prefix - elif c.type == "namespace_use_group": - group = c - elif c.type == "namespace_use_clause": - _record_use_clause(c, "") - if group is not None: - for c in group.children: - if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + for fqn, alias, use_kind in _php_use_declaration_facts(n, source): + if use_kind != "class": + continue # `use function` / `use const` are not class imports + key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if key: + uses.setdefault(key, fqn) return elif t == "class_declaration": for child in n.children: diff --git a/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_first_class_callable.py b/tests/test_php_first_class_callable.py new file mode 100644 index 0000000000..41cffe8545 --- /dev/null +++ b/tests/test_php_first_class_callable.py @@ -0,0 +1,237 @@ +"""PHP 8.1 first-class callables emit ``indirect_call``, not ``calls`` (#15). + +``$obj->method(...)`` creates a ``Closure`` — the method is *named*, not invoked, +so control flow does not transfer at that line. The repo already models +"named but not invoked" as the distinct ``indirect_call`` relation, and PHP only +leaked into ``calls`` because the 8.1 grammar reuses ``member_call_expression`` +for first-class-callable syntax. + +Discriminator (probe-verified on the pinned tree-sitter-php 0.24.1): the +``arguments`` node of ``m(...)`` has exactly one named child, of type +``variadic_placeholder``. ``m()``, ``m(1)`` and ``m(...$args)`` do not. + +Target resolution and the refuse-don't-guess rules are UNCHANGED — only the +relation moves. Every positive test carries a decoy class with an identically +named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _edges(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt, rel): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + edges = { + (edge["source"], edge["target"], edge.get("relation")): edge + for edge in result["edges"] + if edge.get("relation") in ("calls", "indirect_call") + } + return edges, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's declared type can. +_SERVICE = " str: + return ( + "leadHunter->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + # Target resolution is unchanged: same receiver typing, same confidence as + # the ordinary `$this->leadHunter->search([])` call would get. + edge = edges[(index, service_search, "indirect_call")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_nullsafe_first_class_callable_emits_indirect_call(tmp_path: Path): + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return $this->leadHunter?->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + + +def test_first_class_callable_on_this_emits_indirect_call(tmp_path: Path): + """`$this->helper(...)` resolves in-file, so it must re-tag on that path too.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "normalise(...);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + # Decoy: a same-named method in another class must not pick up the edge. + "app/Audit/Normaliser.php": ( + "leadHunter->search(['status' => 'open']);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + edge = edges[(index, service_search, "calls")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + assert edge["context"] == "call" + + +def test_ordinary_this_call_still_emits_calls(tmp_path: Path): + """Regression guard for the in-file path: `$this->normalise()` stays EXTRACTED `calls`.""" + edges, r = _edges(tmp_path, { + "app/Http/Controllers/LeadController.php": ( + "normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + own = _find(r, ".normalise()", "leadcontroller") + + assert (index, own, "calls") in edges + assert (index, own, "indirect_call") not in edges + assert edges[(index, own, "calls")]["confidence"] == "EXTRACTED" + + +def test_spread_argument_is_not_a_first_class_callable(tmp_path: Path): + """`search(...$args)` IS an invocation — only a bare `...` placeholder re-tags.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$args = []; return $this->leadHunter->search(...$args);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + +def test_direct_call_wins_over_first_class_callable_to_the_same_method(tmp_path: Path): + """Both forms in one caller: the real invocation keeps the pair (existing + indirect-dispatch precedence), regardless of which appears first.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$f = $this->leadHunter->search(...);\n" + " return $this->leadHunter->search([]);" + ), + "app/Http/Controllers/TeamController.php": ( + "normalise(...);\n" + " return $this->normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + team_index = _find(r, ".index()", "teamcontroller") + own = _find(r, ".normalise()", "teamcontroller") + assert (team_index, own, "calls") in edges + assert (team_index, own, "indirect_call") not in edges diff --git a/tests/test_php_group_use_kind.py b/tests/test_php_group_use_kind.py new file mode 100644 index 0000000000..f4f2f3177a --- /dev/null +++ b/tests/test_php_group_use_kind.py @@ -0,0 +1,156 @@ +"""Group-form `use function` / `use const` must not claim class names. + +tree-sitter-php puts the `function` / `const` keyword on the *clause* for the +plain form (`use function A\\f;`) but on the *declaration* for the group form +(`use function A\\{f, g};`). `_resolve_php_type_references` only ever inspected +the clause, so group-form members wrongly entered the per-file class-name map +and re-pointed supertype references onto an FQN-labeled external stub. + +Every assertion goes through the public `extract()` seam, with the semantically +equivalent plain form as the side-by-side control. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def _targets(result: dict, relation: str, source_substr: str) -> list[dict]: + """Target nodes of every `relation` edge coming out of a matching source.""" + return [ + _node_by_id(result, e["target"]) + for e in result["edges"] + if e.get("relation") == relation + and source_substr in e.get("source", "").lower() + ] + + +def test_php_group_use_function_behaves_like_the_plain_form(tmp_path: Path): + # `use function Vendor\Sdk\{Render};` imports a *function*, so `Render` in a + # class position is not an explicitly imported class name. The braced form + # must land exactly where the unbraced control lands: on the bare stub the + # legacy unique-label rewire owns, never on an FQN-labeled external stub. + group = _write( + tmp_path / "app/A/UsesGroup.php", + "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) + + +# ── Same-file union / intersection receivers (user story 11, #9) ────────────── +# +# The separate-file negatives above pass for the wrong reason: with the decoys in +# other files, the extractor's LEGACY in-file bare-name arm never runs, so only +# the cross-file resolver is exercised. When the candidate classes live in the +# SAME file as the call, that arm fires and binds the receiver to whichever +# same-named method the label index saw last — pure file order, stamped +# EXTRACTED. A union or intersection annotation proves the receiver has MORE +# THAN ONE possible class, so the extractor must defer to the resolver (which +# refuses an unstamped receiver) instead. +# +# Deletion scope is deliberately limited to `A|B` / `A&B`. The concrete-type +# policy also refuses `self`/`static`/`parent`, primitives and +# `mixed`/`object`/`iterable`/`callable`, but none of those declares MULTIPLE +# candidate classes — `self` in particular makes the in-file match likely +# correct — so they keep today's edge, exactly like a genuinely untyped receiver. + +def _same_file(receiver_decl: str, *, second_class: str = "", call: str = "$this->svc") -> str: + """One PHP file: candidate class(es) plus a Ctrl whose property is `$svc`.""" + return ( + "run();\n" + " }\n" + "}\n" + ) + + +_BETA = "class Beta { public function run(): int { return 2; } }\n" + + +def _ran(calls, go: str) -> list[str]: + """Every ``calls`` target of ``go``. Each fixture below makes exactly one call + (``->run()``), so the whole target list doubles as the assertion.""" + return sorted(tgt for src, tgt in calls if src == go) + + +def test_same_file_union_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha|Beta $svc` with BOTH candidates in the call's own file.""" + calls, r = _calls(tmp_path, { + "app/U.php": _same_file("private Alpha|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [], \ + "a union-typed receiver has no single class — the in-file bare-name " \ + "match would bind it to one of them by file order" + + +def test_same_file_intersection_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha&Beta $svc`: an intersection is named by user story 11 too, and had + no test at all before this ticket.""" + calls, r = _calls(tmp_path, { + "app/I.php": _same_file("private Alpha&Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/UP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_intersection_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/IP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_promoted_param_emits_no_edge(tmp_path: Path): + """A promoted constructor param is a typed property, reached by the same + `this.` key — the refusal must travel that channel too.""" + calls, r = _calls(tmp_path, { + "app/UPP.php": ( + "svc->run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_untyped_property_keeps_its_edge(tmp_path: Path): + """Regression guard for #2's accepted deviation (user story 9): a receiver + with NO annotation keeps today's same-file bare-name edge. Only an + annotation that was PRESENT and refused as multi-typed defers.""" + calls, r = _calls(tmp_path, { + "app/N.php": _same_file("protected $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_untyped_param_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/NP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_this_call_keeps_its_edge(tmp_path: Path): + """Regression guard (user story 9): `$this->method()` never carries a + receiver type and must stay on the in-file arm.""" + calls, r = _calls(tmp_path, { + "app/T.php": ( + "run(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "ctrl")] + + +def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): + """Deletion-scope boundary: `self` is refused by the concrete-type policy but + declares no multiplicity, so it is NOT deferred and keeps today's edge.""" + calls, r = _calls(tmp_path, { + "app/S.php": _same_file("protected self $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_dnf_typed_property_emits_no_edge_and_references_its_types(tmp_path: Path): + """PHP 8.2 disjunctive normal form (`(A&B)|C`) parses as its own node type, + which the property scanner's type-node list did not name — so a DNF property + reached neither the receiver table (it kept minting the bare-name edge this + ticket removes) nor the type-reference walk (its classes went unreferenced). + It is a union at top level, so it refuses like one, and references like one.""" + calls, r = _calls(tmp_path, { + "app/D.php": _same_file("private (Alpha&Beta)|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + refs = { + (edge["source"], edge["target"]) + for edge in r["edges"] if edge.get("relation") == "references" + } + ctrl = _find(r, "Ctrl", "d_ctrl") + assert (ctrl, _find(r, "Alpha", "d_alpha")) in refs + assert (ctrl, _find(r, "Beta", "d_beta")) in refs diff --git a/tests/test_php_name_resolver.py b/tests/test_php_name_resolver.py new file mode 100644 index 0000000000..a39690dbcc --- /dev/null +++ b/tests/test_php_name_resolver.py @@ -0,0 +1,393 @@ +"""PHP `use`-import-aware receiver typing: the decisive refusal (#21, closes #16). + +`_resolve_php_member_calls` used to bind a receiver's short type name through a +corpus-wide index whose only refusal rule was "more than one candidate". A file +that writes `use Vendor\\Sdk\\Client;` has CLAIMED the name `Client` for a class +that is not in the corpus at all — but the resolver never saw `use` statements, +so the lone unrelated `App\\Local\\Client` satisfied the single-definition guard +and minted a wrong `INFERRED 0.8` edge. + +`PhpNameResolver` (mirroring `CsharpNameResolver`) answers with a +`(node_id, decisive)` verdict and is consulted IN FRONT of that fallback: a +claimed name that does not land on an in-corpus class refuses instead of falling +back. The pass is strictly SUBTRACTIVE — every verdict it returns positively is +one the fallback would have returned anyway, so it can only delete edges. +Binding an alias to the right one of several same-short-named classes is a +recall ADDITION and belongs to #22, pinned below by +`test_alias_renaming_to_an_unclaimed_short_name_stays_unresolved`. + +Every test goes through the public `extract()` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# The canonical #16 fixture (spec #18 carries the same copy) plus a decoy that +# declares an identically named method: only the receiver's type tells them +# apart, so a bare-name match would light the decoy up. +_CLIENT = ( + " str: + return ( + "c->send(); }\n" + "}\n" + ) + + +def _sends(calls, caller: str) -> list[str]: + return [tgt for src, tgt in calls if src == caller] + + +# ── the refusal: a claimed name that names no in-corpus class ───────────────── + + +def test_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """#16, cross-file: `use Vendor\\Sdk\\Client;` claims `Client` for a class + this corpus does not contain. The lone `App\\Local\\Client` is a stranger.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], "an out-of-corpus `use` target must refuse" + + +def test_use_alias_outside_corpus_emits_no_edge_same_file(tmp_path: Path): + """#16, same file: the stranger is declared in another namespace block of + the calling file, so the same-file matcher could mint the edge too.""" + calls, r = _calls(tmp_path, { + "app/Mixed/Both.php": ( + "c->send(); }\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], \ + "a same-file stranger must be refused like a cross-file one" + + +def test_group_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """The group form carries its FQN on the declaration's prefix (#19); the + claim it makes is the same one.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\{Client};\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_renaming_alias_over_an_out_of_corpus_target_emits_no_edge(tmp_path: Path): + """`use X as Client;` claims the short name `Client` just as firmly.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Handler as Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_written_fqn_outside_corpus_emits_no_edge(tmp_path: Path): + """The compounding half of #16: an annotation that names the out-of-corpus + class outright, with no `use` statement at all (#20 kept the written form).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\Vendor\\Sdk\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_namespace_relative_annotation_outside_corpus_emits_no_edge(tmp_path: Path): + """A written qualified name with no leading backslash is RELATIVE: inside + `App\\Http`, `Local\\Client` means `App\\Http\\Local\\Client`, which exists + nowhere — not `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the guards: everything the refusal must NOT touch ───────────────────────── + + +def test_in_corpus_use_alias_keeps_its_edge(tmp_path: Path): + """The positive guard: a `use` that names the real in-corpus class binds + exactly as it does today (through the unique-short-name fallback), and the + decoy that merely shares the method name still gets nothing.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + send = _find(r, ".send()", "client") + assert (go, send) in calls + assert calls[(go, send)]["confidence"] == "INFERRED" + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_written_fqn_naming_the_in_corpus_class_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\App\\Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_namespace_relative_annotation_in_corpus_keeps_its_edge(tmp_path: Path): + """Inside namespace `App`, `Local\\Client` IS `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/I.php": _caller(annotation="Local\\Client", namespace="App"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_use_function_import_makes_no_class_claim(tmp_path: Path): + """`use function` / `use const` import no class name (#26), in either + spelling — so they claim nothing and the fallback runs untouched.""" + for index, uses in enumerate(( + "use function Vendor\\Sdk\\Client;\n", + "use function Vendor\\Sdk\\{Client};\n", + "use const Vendor\\Sdk\\Client;\n", + )): + calls, r = _calls(tmp_path / f"case{index}", { + **_CORPUS, "app/Http/I.php": _caller(uses), + }) + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls, uses + assert (go, _find(r, ".send()", "recorder")) not in calls, uses + + +def test_unclaimed_short_name_still_falls_back(tmp_path: Path): + """No `use`, no qualified form: the resolver knows nothing about the name + and the corpus-wide fallback decides, exactly as before.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_alias_renaming_to_an_unclaimed_short_name_stays_unresolved(tmp_path: Path): + """The #21/#22 boundary. `use App\\Local\\Client as Api;` names an in-corpus + class, but the WRITTEN short name is `Api` and nothing in the corpus is + called that, so today's fallback finds nothing and this ticket adds no edge: + binding the alias to `App\\Local\\Client` is #22's recall win.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client as Api;\n", annotation="Api"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the same verdicts across an incremental rebuild ─────────────────────────── +# +# The `use` map belongs to the CALLING file, which `graphify update`/watch always +# re-dispatch, so the refusal needs no persisted marker (spec #18, decision 3). +# The context below is assembled exactly as `test_php_member_calls.py` mirrors +# watch.py: a field subset of the persisted nodes plus contains/method edges, +# both scoped to the files that are NOT re-extracted. + +_CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_non_class_types", + "_php_interfaces") + + +def _watch_resolution_context(result: dict, unchanged: set[str]): + nodes = [] + for node in result["nodes"]: + if not node.get("id") or node.get("source_file") not in unchanged: + continue + ctx = {"id": node["id"]} + ctx.update({field: node.get(field) for field in _CTX_NODE_FIELDS}) + ctx.update({m: node[m] for m in _CTX_MARKERS if node.get(m)}) + nodes.append(ctx) + edges = [ + { + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": edge.get("source_file"), + } + for edge in result["edges"] + if edge.get("relation") in ("contains", "method") + and edge.get("source_file") in unchanged + ] + return nodes, edges + + +def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed} + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_CALLER = "app/Http/I.php" + + +def test_alias_refusal_survives_incremental_rebuild(tmp_path: Path): + """`App\\Local\\Client` is unchanged and therefore not dispatched; the claim + lives in the caller, which is, so the refusal must fire either way.""" + (full_calls, _), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use Vendor\\Sdk\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + assert _sends(full_calls, go) == [], "full-build baseline must refuse" + assert _sends(inc_calls, go) == [], \ + "an undispatched defining file must not resurrect the false edge" + + +def test_in_corpus_alias_still_resolves_incrementally(tmp_path: Path): + """Positive control for the test above: the defining file's declared FQN is + unavailable on an incremental run, so the class is corroborated against its + PSR-4 path instead — and the edge survives, decoy still empty.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use App\\Local\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + # The target lives in an unchanged file, so its id comes from the full run. + send = _find(full, ".send()", "client") + assert (go, send) in full_calls + assert (go, send) in inc_calls, \ + "a rebuild must keep an edge whose `use` names the in-corpus class" + assert (go, _find(full, ".send()", "recorder")) not in inc_calls + + +def test_non_psr4_layout_keeps_its_edge_incrementally(tmp_path: Path): + """Composer maps a namespace PREFIX onto a directory (`App\\Weird\\` -> + `src/`), so `App\\Weird\\Odd` legitimately lives at `src/Odd.php`. The full + run corroborates the `use` against the namespace that file DECLARES; the + incremental run no longer has the declaration and must not read the shorter + path as a contradiction — a stripped prefix looks exactly like one.""" + caller = ( + "o->ping(); }\n" + "}\n" + ) + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "src/Odd.php": ( + " Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _facts(tmp_path: Path, body: str) -> list[dict]: + """Raw-call facts of one PHP file (the callee is never defined in it, so + every member call stays unresolved and reaches `raw_calls`).""" + path = _write(tmp_path / "app/Http/Controllers/LeadController.php", body) + return extract_php(path).get("raw_calls", []) + + +def _fact(facts: list[dict], receiver: str) -> dict: + matches = [f for f in facts if f.get("receiver") == receiver] + assert len(matches) == 1, facts + return matches[0] + + +def _class(members: str) -> str: + return ( + "c->send(); }\n" + )) + + fact = _fact(facts, "this.c") + assert fact["callee"] == "send" + assert fact["receiver_type"] == "Client" + assert fact["receiver_type_qualified"] == "\\Vendor\\Sdk\\Client" + + +def test_qualified_promoted_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function __construct(private \\App\\Services\\LeadHunterService $svc) {}\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(\\App\\Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_local_new_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_nullable_qualified_property_stamps_qualified(tmp_path: Path): + """`?\\A\\B` unwraps to one concrete type — the qualified form must survive + the unwrap, not just the short name.""" + facts = _facts(tmp_path, _class( + " private ?\\App\\Services\\LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_namespace_relative_annotation_stamps_written_form(tmp_path: Path): + """`Services\\X` is qualified but RELATIVE to the current namespace. The + extractor stamps what was written; resolving it is the resolver's job.""" + facts = _facts(tmp_path, _class( + " public function handle(Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "Services\\LeadHunterService" + + +# ── fact shape: unqualified annotations are unchanged ───────────────────────── + + +def test_unqualified_property_stamps_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " private LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +def test_unqualified_param_and_local_stamp_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(LeadHunterService $svc): array {\n" + " $other = new AuditLog();\n" + " return $svc->search([]) + $other->search([]);\n" + " }\n" + )) + + param = _fact(facts, "svc") + assert param["receiver_type"] == "LeadHunterService" + assert param.get("receiver_type_qualified") is None + local = _fact(facts, "other") + assert local["receiver_type"] == "AuditLog" + assert local.get("receiver_type_qualified") is None + + +def test_union_typed_receiver_stamps_neither_field(tmp_path: Path): + """A multi-class annotation is a refusal (#9): it stamps no type, and the + qualified field must not resurrect one.""" + facts = _facts(tmp_path, _class( + " public function handle(\\App\\A|\\App\\B $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert "receiver_type" not in fact + assert "receiver_type_qualified" not in fact + + +def test_same_class_written_two_ways_keeps_short_name(tmp_path: Path): + """Two `new`s naming the same SHORT name through different written forms + used to bind that short name, and still do — the qualified evidence is + contradictory, so it is dropped rather than poisoning the binding.""" + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new LeadHunterService();\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +# ── resolution parity through the public extract() seam ─────────────────────── + +_SERVICE = ( + " str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def test_qualified_annotation_still_resolves_by_short_name(tmp_path: Path): + """Parity: threading the qualified form changes no edge. The written FQN + names the in-corpus class here, and the resolution is exactly today's — + INFERRED 0.8 off the short name, decoy untouched.""" + calls, r = _extract(tmp_path, { + "app/Services/LeadHunterService.php": _SERVICE, + "app/Audit/AuditLog.php": _DECOY, + "app/Http/Controllers/LeadController.php": ( + "svc->search([]); }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 diff --git a/tests/test_php_use_imports.py b/tests/test_php_use_imports.py new file mode 100644 index 0000000000..933e9be40e --- /dev/null +++ b/tests/test_php_use_imports.py @@ -0,0 +1,152 @@ +"""PHP `use`-import capture. + +Every assertion goes through the public `extract()` seam. These are +metadata-shape tests: the `imports` edges themselves (their targets) must stay +exactly as they were — only `target_fqn` / `alias` / `use_kind` are new. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _import_facts(result: dict) -> set[tuple[str | None, str | None, str | None]]: + """(target_fqn, alias, use_kind) for every `imports` edge in the graph.""" + facts = set() + for e in result["edges"]: + if e.get("relation") != "imports": + continue + md = e.get("metadata") or {} + facts.add((md.get("target_fqn"), md.get("alias"), md.get("use_kind"))) + return facts + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def test_php_plain_use_captures_target_fqn(tmp_path: Path): + f = _write( + tmp_path / "app/Http/I.php", + "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'" },