From f310fb541f7d021d0f594cd27bab5cf766c4bf87 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:57:36 -0300 Subject: [PATCH 1/2] fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User story 11 promised no `calls` edge for a union- or intersection-typed receiver. The cross-file resolver honoured it, but the extractor's legacy in-file bare-name arm did not: `_php_defer` was derived from whether a `receiver_type` had been STAMPED, so an annotation REFUSED by the concrete-type policy looked exactly like no annotation at all. A one-file `private Alpha|Beta $svc; $this->svc->run();` therefore bound to whichever `run()` the file's label index saw last — file order — at EXTRACTED confidence. Pre-existing, not a branch regression: it reproduces at the merge-base 4e7e6b1. The receiver table now distinguishes three states for a key: a concrete type (resolve it), PRESENT-but-None (annotation refused as multi-class, defer), and ABSENT (no annotation, keep today's in-file match). Precedence is concrete > refusal > absent, so a union-typed param later assigned a `new T()` still resolves to T while a poisoned one stays refused. Deletion scope is deliberately narrow, since deferring removes edges that exist today: only union (`A|B`) and intersection (`A&B`) annotations defer — including `A|null`, which is semantically `?A` but parses as a union node. The concrete-type policy's other refusals declare no multiplicity and keep their in-file edge: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched, preserving #2's accepted deviation and user story 9. Named in the CHANGELOG. Tests (all through the `extract()` seam): same-file union and intersection variants for properties, params and a promoted param — the separate-file negatives at tests/test_php_member_calls.py:211 spread `**_CORPUS`, which puts the decoys in other files, so the in-file arm never ran and they passed for the wrong reason; no intersection test existed at all. Plus regression guards that untyped properties/params, `$this->method()` and a `self`-typed property keep their same-file edges, locking the deletion scope. 5 red before the fix, 9 green after. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- graphify/extractors/engine.py | 104 +++++++++++++++---- tests/test_php_member_calls.py | 184 +++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c40030a5..c3e8187cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. -- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). +- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. ## 0.9.33 (unreleased) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index bd7bbc934..d38a44a7a 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -740,6 +740,29 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: return None +# Type expressions that declare MORE THAN ONE possible class for a receiver: +# union (`A|B`) and intersection (`A&B`) — node names probe-verified against +# tree-sitter-php 0.24.1. +_PHP_MULTI_TYPE_NODES = frozenset({"union_type", "intersection_type"}) + + +def _php_multi_typed_annotation(type_node) -> bool: + """True when a PHP type annotation names more than one candidate class (#9). + + `_php_concrete_type_name` refuses several shapes, and the two reasons for + refusal must be told apart at the call site (user story 11): a receiver whose + annotation is a UNION or INTERSECTION provably has several possible classes, + so an in-file bare-name match can only pick one of them by file order and + must be suppressed. The policy's other refusals — `self`/`static`/`parent`, + primitives, `mixed`/`object`/`iterable`/`callable`, a nullable wrapping more + than one type — declare no such multiplicity, so they deliberately keep + today's in-file edge, exactly like a genuinely untyped receiver (#2's + accepted deviation, user story 9). `self` especially: it names the calling + class, whose methods usually ARE the in-file match. + """ + return type_node is not None and type_node.type in _PHP_MULTI_TYPE_NODES + + # Subtrees that are a DIFFERENT binding scope than the method being scanned: # their assignments must not type the enclosing method's variables. Closures are # deliberately absent — their calls are attributed to the enclosing method, so @@ -761,8 +784,8 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: def _php_method_receiver_types( method_node, source: bytes, - field_types: dict[str, str], -) -> dict[str, str]: + field_types: dict[str, str | None], +) -> dict[str, str | None]: """Build the receiver type table visible to one PHP method (#1682). ``this.`` keys come from the declaring class's typed properties and @@ -777,9 +800,18 @@ def _php_method_receiver_types( target, a list-destructuring element, or a `global`/`static` statement rebinding it to other storage. Poisoning is order-independent, which is why it can be decided from a single unordered walk. + + A key mapped to None is PRESENT but unresolved: its annotation named several + candidate classes (`A|B`, `A&B`), which the call site must tell apart from an + ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > + multi-class refusal > absent, so a union-typed param later assigned a `new T()` + still resolves to T, while a poisoned union-typed one stays refused. """ - table = {f"this.{name}": type_name for name, type_name in field_types.items()} + table: dict[str, str | None] = { + f"this.{name}": type_name for name, type_name in field_types.items() + } method_types: dict[str, str] = {} + multi_typed_params: set[str] = set() ambiguous: set[str] = set() def poison(name: str) -> None: @@ -832,13 +864,17 @@ def new_type_name(node) -> str | None: for param in params.children: if param.type not in ("simple_parameter", "property_promotion_parameter"): continue - type_name = _php_concrete_type_name( - param.child_by_field_name("type"), source - ) + type_node = param.child_by_field_name("type") + type_name = _php_concrete_type_name(type_node, source) name_node = param.child_by_field_name("name") if name_node is not None and type_name: - # Untyped / union / primitive params simply stay unbound. + # Untyped / primitive params simply stay unbound. bind(_read_text(name_node, source).lstrip("$"), type_name) + elif name_node is not None and _php_multi_typed_annotation(type_node): + # A union/intersection param is not BOUND — `bind(None)` would + # poison it, and poisoning is indistinguishable from untyped. + # Mark it instead, and let the merge below apply precedence. + multi_typed_params.add(_read_text(name_node, source).lstrip("$")) body = method_node.child_by_field_name("body") stack = list(body.children) if body is not None else [] @@ -884,6 +920,10 @@ def new_type_name(node) -> str | None: table.update(method_types) for name in ambiguous: table.pop(name, None) + for name in multi_typed_params: + # setdefault, so a concrete type learned from a `new` wins; a poisoned + # name (popped just above) falls back to the multi-class refusal. + table.setdefault(name, None) return table @@ -2656,7 +2696,10 @@ def _extract_generic( csharp_method_scopes: dict[int, tuple[object, str]] = {} # PHP receiver typing (#1682): typed properties and constructor-promoted # params of the declaring class, keyed `this.` per method scope. - php_field_types: dict[str, dict[str, str]] = {} + # `prop -> declared type` per class. A value of None means the annotation was + # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call + # site must tell apart from an ABSENT key = no annotation at all (#9). + php_field_types: dict[str, dict[str, str | None]] = {} php_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() @@ -3500,8 +3543,12 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # #1682: remember the property's declared type so a later # `$this->prop->method()` resolves against it. Only a single # concrete class name counts — unions/primitives are refused. + # A multi-class annotation is recorded as None so the call site + # can defer rather than bare-name match it (#9); every other + # refusal leaves the property out of the table entirely. type_name = _php_concrete_type_name(c, source) - if type_name: + multi_typed = _php_multi_typed_annotation(c) + if type_name or multi_typed: fields = php_field_types.setdefault(parent_class_nid, {}) for pe in node.children: if pe.type != "property_element": @@ -3841,11 +3888,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = sub break # #1682: a promoted param IS a typed class property — - # record it in the same `this.` receiver table. + # record it in the same `this.` receiver table, + # multi-class annotations included as None (#9). if is_promoted and parent_class_nid: promoted_type = _php_concrete_type_name(type_node, source) v = p.child_by_field_name("name") - if promoted_type and v is not None: + if v is not None and ( + promoted_type or _php_multi_typed_annotation(type_node) + ): php_field_types.setdefault(parent_class_nid, {})[ _read_text(v, source).lstrip("$") ] = promoted_type @@ -4465,7 +4515,8 @@ def _php_class_const_scope(n) -> str | None: def walk_calls( node, caller_nid: str, - receiver_types: dict[str, str] | None = None, + # PHP entries may map a key to None — see _php_method_receiver_types. + receiver_types: dict[str, str | None] | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -4812,18 +4863,33 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - # PHP (#1682): defer ONLY when the receiver's type is actually - # known — a typed `$this->prop->m()` must not bare-match an - # unrelated same-named method in this file. Plain `$this->m()` - # and untyped receivers keep today's in-file match, since the - # resolver could add nothing for them anyway. + # PHP (#1682): defer when the receiver's type is actually known — + # a typed `$this->prop->m()` must not bare-match an unrelated + # same-named method in this file. Plain `$this->m()` and untyped + # receivers keep today's in-file match, since the resolver could + # add nothing for them anyway. + # + # ALSO defer when the annotation was PRESENT but named several + # candidate classes (`A|B`, `A&B`) — user story 11 (#9). Such a + # receiver stamps no type, so before this the refusal was + # indistinguishable from "untyped" and the in-file arm bound the + # call to whichever same-named method came last in the file, at + # EXTRACTED confidence. The receiver table encodes the difference: + # a PRESENT key mapped to None is a refused multi-class + # annotation, an ABSENT key is no annotation at all. _php_receiver_type: str | None = None + _php_multi_typed_receiver = False if config.ts_module == "tree_sitter_php": if php_inline_new_type: _php_receiver_type = php_inline_new_type elif member_receiver and member_receiver != "this": - _php_receiver_type = (receiver_types or {}).get(member_receiver) - _php_defer = bool(_php_receiver_type) + _php_types = receiver_types or {} + _php_receiver_type = _php_types.get(member_receiver) + _php_multi_typed_receiver = ( + _php_receiver_type is None + and member_receiver in _php_types + ) + _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver if _java_defer or _php_defer or ( is_member_call and member_receiver diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index a8ef926fc..2eb355b25 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -1876,3 +1876,187 @@ def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): go = _find(inc, ".go()", "dispatcher") assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls assert not _notified(inc_calls, go) + + +# ── Same-file union / intersection receivers (user story 11, #9) ────────────── +# +# The separate-file negatives above pass for the wrong reason: with the decoys in +# other files, the extractor's LEGACY in-file bare-name arm never runs, so only +# the cross-file resolver is exercised. When the candidate classes live in the +# SAME file as the call, that arm fires and binds the receiver to whichever +# same-named method the label index saw last — pure file order, stamped +# EXTRACTED. A union or intersection annotation proves the receiver has MORE +# THAN ONE possible class, so the extractor must defer to the resolver (which +# refuses an unstamped receiver) instead. +# +# Deletion scope is deliberately limited to `A|B` / `A&B`. The concrete-type +# policy also refuses `self`/`static`/`parent`, primitives and +# `mixed`/`object`/`iterable`/`callable`, but none of those declares MULTIPLE +# candidate classes — `self` in particular makes the in-file match likely +# correct — so they keep today's edge, exactly like a genuinely untyped receiver. + +def _same_file(receiver_decl: str, *, second_class: str = "", call: str = "$this->svc") -> str: + """One PHP file: candidate class(es) plus a Ctrl whose property is `$svc`.""" + return ( + "run();\n" + " }\n" + "}\n" + ) + + +_BETA = "class Beta { public function run(): int { return 2; } }\n" + + +def _ran(calls, go: str) -> list[str]: + """Every ``calls`` target of ``go``. Each fixture below makes exactly one call + (``->run()``), so the whole target list doubles as the assertion.""" + return sorted(tgt for src, tgt in calls if src == go) + + +def test_same_file_union_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha|Beta $svc` with BOTH candidates in the call's own file.""" + calls, r = _calls(tmp_path, { + "app/U.php": _same_file("private Alpha|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [], \ + "a union-typed receiver has no single class — the in-file bare-name " \ + "match would bind it to one of them by file order" + + +def test_same_file_intersection_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha&Beta $svc`: an intersection is named by user story 11 too, and had + no test at all before this ticket.""" + calls, r = _calls(tmp_path, { + "app/I.php": _same_file("private Alpha&Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/UP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_intersection_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/IP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_promoted_param_emits_no_edge(tmp_path: Path): + """A promoted constructor param is a typed property, reached by the same + `this.` key — the refusal must travel that channel too.""" + calls, r = _calls(tmp_path, { + "app/UPP.php": ( + "svc->run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_untyped_property_keeps_its_edge(tmp_path: Path): + """Regression guard for #2's accepted deviation (user story 9): a receiver + with NO annotation keeps today's same-file bare-name edge. Only an + annotation that was PRESENT and refused as multi-typed defers.""" + calls, r = _calls(tmp_path, { + "app/N.php": _same_file("protected $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_untyped_param_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/NP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_this_call_keeps_its_edge(tmp_path: Path): + """Regression guard (user story 9): `$this->method()` never carries a + receiver type and must stay on the in-file arm.""" + calls, r = _calls(tmp_path, { + "app/T.php": ( + "run(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "ctrl")] + + +def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): + """Deletion-scope boundary: `self` is refused by the concrete-type policy but + declares no multiplicity, so it is NOT deferred and keeps today's edge.""" + calls, r = _calls(tmp_path, { + "app/S.php": _same_file("protected self $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] From fe25b51c49483db41dcd1393a8d7962f8b258e3d Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:59:23 -0300 Subject: [PATCH 2/2] fix(php): recognize PHP 8.2 DNF property and promoted-param types (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `private (A&B)|C $x;` parses as a `disjunctive_normal_form_type` node, which neither the property scanner nor the promoted-param scanner named among the type shapes they accept. A DNF-typed property was therefore skipped outright and invisible twice over: * it never reached the receiver type table, so it kept minting the same-file bare-name `calls` edge the previous commit removes — a DNF type is a union at top level, so it has no single receiver class either; * `_php_collect_type_refs` never walked it, so none of its classes got a `references` edge, unlike the plain union property beside it. Naming the node in both scanners fixes both halves at once — they read the same type node, one for the receiver table and one for the reference walk, so the two cannot be separated without a throwaway DNF-only scan. Split out from the union/intersection commit because the reference edges are a behavior addition beyond issue 9's letter. `_php_multi_typed_annotation` gains the node, so DNF refuses exactly like `A|B` does; the deletion scope stated in the previous commit widens by this one shape and the CHANGELOG says so. Test asserts both halves through the `extract()` seam: no `calls` edge, and `references` edges to the DNF's classes. Red before, green after. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/engine.py | 16 ++++++++++++---- tests/test_php_member_calls.py | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e8187cc..0666935fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. - Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d38a44a7a..99131a67e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -741,9 +741,12 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: # Type expressions that declare MORE THAN ONE possible class for a receiver: -# union (`A|B`) and intersection (`A&B`) — node names probe-verified against +# union (`A|B`), intersection (`A&B`) and PHP 8.2 disjunctive-normal-form +# (`(A&B)|C`, a union at top level) — node names probe-verified against # tree-sitter-php 0.24.1. -_PHP_MULTI_TYPE_NODES = frozenset({"union_type", "intersection_type"}) +_PHP_MULTI_TYPE_NODES = frozenset({ + "union_type", "intersection_type", "disjunctive_normal_form_type", +}) def _php_multi_typed_annotation(type_node) -> bool: @@ -3537,7 +3540,11 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: and parent_class_nid): for c in node.children: if c.type not in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + # PHP 8.2 `(A&B)|C`, absent from this list until + # #9 and so invisible to both the receiver table + # and the type-reference walk below. + "disjunctive_normal_form_type"): continue line = node.start_point[0] + 1 # #1682: remember the property's declared type so a later @@ -3884,7 +3891,8 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = None for sub in p.children: if sub.type in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + "disjunctive_normal_form_type"): type_node = sub break # #1682: a promoted param IS a typed class property — diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 2eb355b25..a9c0ecdfa 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -2060,3 +2060,25 @@ def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): go = _find(r, ".go()", "ctrl") assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_dnf_typed_property_emits_no_edge_and_references_its_types(tmp_path: Path): + """PHP 8.2 disjunctive normal form (`(A&B)|C`) parses as its own node type, + which the property scanner's type-node list did not name — so a DNF property + reached neither the receiver table (it kept minting the bare-name edge this + ticket removes) nor the type-reference walk (its classes went unreferenced). + It is a union at top level, so it refuses like one, and references like one.""" + calls, r = _calls(tmp_path, { + "app/D.php": _same_file("private (Alpha&Beta)|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + refs = { + (edge["source"], edge["target"]) + for edge in r["edges"] if edge.get("relation") == "references" + } + ctrl = _find(r, "Ctrl", "d_ctrl") + assert (ctrl, _find(r, "Alpha", "d_alpha")) in refs + assert (ctrl, _find(r, "Beta", "d_beta")) in refs