fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9) - #25
Merged
Merged
Conversation
…receivers (#9) 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #9 (spec #1, user story 11).
What
A union- or intersection-typed PHP receiver no longer mints a same-file bare-name
callsedge. The legacy in-file matcher derived its defer decision from whether a receiver type was stamped, which made "annotation present but refused" indistinguishable from "no annotation" — soprivate Alpha|Beta $svc; $this->svc->run();bound to whichever same-named method came last in the file, at EXTRACTED confidence. The receiver table now carries three states (concrete type / present-but-refused / absent), and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing.Two commits, each independently green:
f310fb5— issue PHP: union- and intersection-typed receivers still mint a same-file bare-name edge (user story 11) #9 proper: union (A|B) + intersection (A&B) refusal, 9 tests. Deletion scope stated deliberately in the CHANGELOG: only multi-class annotations defer;self/static/parent, primitives,mixed/object/iterable/callable, genuinely untyped receivers, and$this->method()keep today's in-file behavior. Known trade:A|null(semantically?A) parses as a union node and loses its same-file edge, per US 11's literal wording.fe25b51— PHP 8.2 DNF ((A&B)|C) property/promoted-param types were invisible to the scanners entirely; now refused like a union and emitting thereferencesedges a plain union already did. Split out because it goes beyond PHP: union- and intersection-typed receivers still mint a same-file bare-name edge (user story 11) #9's letter (1 test).Test evidence
beta_runby file order, matching the issue repro), then 1 DNF failure before the second commit.uv run --frozen --extra ollama pytest→ 4034 passed / 36 skipped / 0 failed (baseline atv8@102ae79: 4024 / 36; +10 new tests).union_type,intersection_type,disjunctive_normal_form_type) probe-verified against tree-sitter-php 0.24.1.🤖 Generated with Claude Code