Parent
Spec: #1 (upstream: Graphify-Labs#1682). Found in independent code review of feat/php-member-calls-1682.
Problem
Spec #1, user story 11:
As a graph consumer, I want no calls edge when the receiver is untyped, union-typed, or intersection-typed, so that common method names (log(), find(), execute()) never produce false cross-class edges.
Union- and intersection-typed receivers still mint a false cross-class edge when the candidate methods live in the same file as the call.
Mechanism
A union or intersection type is correctly refused by the concrete-type policy, so it stamps no receiver_type. But _php_defer is derived from whether a type was stamped (graphify/extractors/engine.py:4737-4742):
_php_receiver_type: str | None = None
if config.ts_module == "tree_sitter_php":
if php_inline_new_type:
_php_receiver_type = php_inline_new_type
elif member_receiver and member_receiver != "this":
_php_receiver_type = (receiver_types or {}).get(member_receiver)
_php_defer = bool(_php_receiver_type)
So a refused type is indistinguishable from an untyped one, _php_defer is False, and the legacy in-file bare-name match fires. PHP is additionally exempted from the capitalized-receiver strip that would otherwise catch it (engine.py:4748).
This follows from #2's accepted deviation — "defer only when a receiver type was stamped, preserving untyped same-file edges" — but that wording names only untyped, whereas US 11 groups union and intersection alongside it. The residual is undocumented.
Repro (verified through the extract() seam)
One file, U.php:
<?php
namespace App;
class Alpha { public function run(): int { return 1; } }
class Beta { public function run(): int { return 2; } }
class Ctrl {
private Alpha|Beta $svc;
public function go(): int { return $this->svc->run(); }
}
Result — bound to Beta purely by bare-name match order, at the strongest confidence label:
Ctrl::go -> Beta::run [EXTRACTED]
private Gamma&Delta $svc; behaves identically.
Pre-existing, not a regression: the identical edge reproduces at the branch's merge-base 4e7e6b1. The branch leaves US 11 unmet rather than breaking it.
The guarding tests must be restructured
The existing negatives structurally cannot catch this, so a fixer must not trust them as coverage:
tests/test_php_member_calls.py:211 — test_union_typed_property_emits_no_edge spreads **_CORPUS, which puts the decoy classes (LeadHunterService, AuditLog) in separate files from the controller. The in-file bare-name arm therefore never runs, and the test passes for the wrong reason. It needs a variant whose decoy sits in the same file as the call.
- No intersection-typed test exists at all — grep for
intersection (or a & type) in tests/test_php_member_calls.py returns nothing, despite US 11 naming intersection explicitly.
What to build
Make a refused PHP receiver type suppress the in-file bare-name match, while genuinely untyped receivers keep it (preserving #2's accepted deviation and user story 9). That likely means distinguishing "no type annotation" from "type annotation present but refused" at the stamping site, rather than collapsing both to _php_defer is False.
Acceptance criteria
Blocked by
None.
Parent
Spec: #1 (upstream: Graphify-Labs#1682). Found in independent code review of
feat/php-member-calls-1682.Problem
Spec #1, user story 11:
Union- and intersection-typed receivers still mint a false cross-class edge when the candidate methods live in the same file as the call.
Mechanism
A union or intersection type is correctly refused by the concrete-type policy, so it stamps no
receiver_type. But_php_deferis derived from whether a type was stamped (graphify/extractors/engine.py:4737-4742):So a refused type is indistinguishable from an untyped one,
_php_deferisFalse, and the legacy in-file bare-name match fires. PHP is additionally exempted from the capitalized-receiver strip that would otherwise catch it (engine.py:4748).This follows from #2's accepted deviation — "defer only when a receiver type was stamped, preserving untyped same-file edges" — but that wording names only untyped, whereas US 11 groups union and intersection alongside it. The residual is undocumented.
Repro (verified through the
extract()seam)One file,
U.php:Result — bound to
Betapurely by bare-name match order, at the strongest confidence label:private Gamma&Delta $svc;behaves identically.Pre-existing, not a regression: the identical edge reproduces at the branch's merge-base
4e7e6b1. The branch leaves US 11 unmet rather than breaking it.The guarding tests must be restructured
The existing negatives structurally cannot catch this, so a fixer must not trust them as coverage:
tests/test_php_member_calls.py:211—test_union_typed_property_emits_no_edgespreads**_CORPUS, which puts the decoy classes (LeadHunterService,AuditLog) in separate files from the controller. The in-file bare-name arm therefore never runs, and the test passes for the wrong reason. It needs a variant whose decoy sits in the same file as the call.intersection(or a&type) intests/test_php_member_calls.pyreturns nothing, despite US 11 naming intersection explicitly.What to build
Make a refused PHP receiver type suppress the in-file bare-name match, while genuinely untyped receivers keep it (preserving #2's accepted deviation and user story 9). That likely means distinguishing "no type annotation" from "type annotation present but refused" at the stamping site, rather than collapsing both to
_php_defer is False.Acceptance criteria
callsedgecallsedge$this->method()same-file calls unchanged (user story 9)feat/php-member-calls-1682: 3988 passed / 36 skipped)Blocked by
None.