Skip to content

fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9) - #25

Merged
filipechagas merged 2 commits into
v8from
fix/php-union-intersection-9
Aug 5, 2026
Merged

fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9)#25
filipechagas merged 2 commits into
v8from
fix/php-union-intersection-9

Conversation

@filipechagas

Copy link
Copy Markdown
Collaborator

Fixes #9 (spec #1, user story 11).

What

A union- or intersection-typed PHP receiver no longer mints a same-file bare-name calls edge. 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" — so private 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:

Test evidence

  • Red first: 5 failures against unfixed code (all binding beta_run by file order, matching the issue repro), then 1 DNF failure before the second commit.
  • Green: full suite uv run --frozen --extra ollama pytest4034 passed / 36 skipped / 0 failed (baseline at v8 @ 102ae79: 4024 / 36; +10 new tests).
  • Independently reproduced by the orchestrator in the worktree: 4034 passed / 36 skipped.
  • AST node names (union_type, intersection_type, disjunctive_normal_form_type) probe-verified against tree-sitter-php 0.24.1.

🤖 Generated with Claude Code

filipechagas and others added 2 commits August 5, 2026 19:57
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PHP: union- and intersection-typed receivers still mint a same-file bare-name edge (user story 11)

1 participant