Summary
This issue is a consolidated defect report for PHP support in graphify. It extends #1682 and adds every related defect we found while we fixed #1682. We wrote the fixes in our public fork, lawnstarter/graphify, and we validated them on a production Laravel codebase.
This issue supersedes our seven open PRs: #2492, #2502, #2503, #2505, #2506, #2516, #2536. Seven stacked PRs are hard to review and hard to keep current. We close all seven. One new PR replaces them and fixes this issue.
Test corpus
Almost all measurements below come from one codebase: api.lawnstarter.com. It is a PHP 8.3 / Laravel monolith. Its graph has 46,406 nodes (48,500 nodes in a later snapshot, used for the query-seeding measurements). We call this "the corpus" below. All numbers are from real builds of this corpus, before and after each fix.
Defect families
1. PHP member calls resolve to no calls edge (#1682)
The extractor emits calls edges for ClassName::method() only. It emits no edge for:
$this->service->method() through a constructor-injected property. This is the dominant call pattern in Laravel code.
$obj->method() on a typed local variable or a typed parameter.
(new Service())->method().
On the corpus, LeadHunterService has grep-verified callers but zero inbound calls edges. The graph looks complete but is not. This is the original report in #1682.
2. The extractor discards use statement facts
_import_php keeps only the trailing short name of a use statement (raw.split("\\")[-1]). The written FQN, the alias, and the kind (class / function / const) are lost. Downstream passes then cannot know which class a file claims.
Also, tree-sitter-php puts the function/const keyword in a different position for the group form. The result: use function A\{render}; claims render as a class name. A class I extends Render in the same file then re-points its inherits edge to the wrong target.
3. Member-call resolvers and import edges leak across languages
The receiver-type indexes were corpus-wide, not per-language. Two wrong results follow:
- A TypeScript
Lead.search({}) call binds a Python class Lead. This is a wrong cross-language edge, at EXTRACTED (1.0) confidence.
- In reverse, a foreign class that only shares a short name pushes the single-definition guard to 2. The correct same-language edge is then silently suppressed.
Separately, an imports edge dies when a same-stem foreign file sits beside its target (lead.md beside lead.py). Id disambiguation salts both file nodes, and the edge dangles. This affects Python, PowerShell, Zig, Rust, Pascal, Elixir, and Bash.
4. Wrong or mislabeled member-call edges
- A PHP 8.1 first-class callable,
$obj->method(...), creates a Closure. It does not invoke the method. It still got a calls edge.
- A union-typed receiver (
private Alpha|Beta $svc;) fell through to the in-file bare-name matcher. It bound whichever same-named method the file held, at EXTRACTED confidence.
- A PHP 8.2 DNF type (
private (A&B)|C $x;) was not handled at all.
- The extractor flattened written qualified types (
\Vendor\Sdk\Client became Client).
- A
use Vendor\Sdk\Client; claim was ignored. The resolver bound the one unrelated in-corpus Client instead. Measured on the corpus: 3 wrong edges of this class.
5. A claimed type name cannot bind to its declared FQN
When several in-corpus classes share the short name X, a file with use App\Alpha\X; refused instead of selecting App\Alpha\X. A renaming alias, use App\Alpha\X as Y;, could never resolve, because nothing in the corpus is named Y. The bindings must also survive graphify update: without persisted declared-FQN facts, every added edge vanishes on the first incremental rebuild.
6. PHP node identity: interfaces, traits, and enums mint no node
_PHP_CONFIG.class_types held class_declaration only. On the corpus, 291 declarations had no node: 142 interfaces, 30 traits, 119 enums. The fan-in scattered onto sourceless stubs, per-file stubs, and file nodes. Consequences:
- A sourceless stub shadows the real name. 18 stubs shared one label on the corpus.
graphify explain answered from an arbitrary stub. graphify affected refused the same name with "No unique node match".
- About 19,000
imports edges dangled with no node behind them.
parent::setUp() emitted a raw call with the literal callee name parent. The cross-file pass then bound it to any ->parent() accessor. Measured: 1,698 fabricated inbound calls edges on one Eloquent model accessor.
graphify query and shortest_path preferred stubs on exact score ties. shortest_path returned a false "No directed path found" for a reachable pair.
7. A PHP function call binds to a method or a class, cross-file
In PHP, a bare name(...) call can only invoke a function. The shared cross-file pass matched by normalized label, which erases the member marker. So Laravel's event(...) helper bound to a test method named event(). Measured: 848 fabricated inbound calls edges on one test method, from 368 distinct files, 308 of them not tests. The same family covered config() (290 edges), dispatch() (186), and request() (15). The class half is real too: report($e) bound class Checkr\Resources\Report through the case-insensitive fold (7 edges).
8. Natural-language query seeding fails on impact questions (follow-up to #2507)
We filed #2507. Upstream fixed one of the three reported failure modes in 0.9.35 (the relational-verb demotion). Two modes remain:
- Class-node stranding. "callers of X" infers a
call context filter, but a class node owns no call edges. Measured: "callers of ChargeCustomerService" returns 1 node and 0 of its 3 grep-verified production callers, delivered confidently.
- Seed explosion. Generic nouns in the question seed their own hub nodes. Measured: "what code uses ChargeCustomerService to charge a customer" traverses 2,286 nodes and shows 0 of 3 callers.
9. A receiver typed by a constructor-body assignment resolves nothing
The pre-promotion Laravel idiom: an untyped property, a @var docblock, and a typed constructor parameter that the body assigns across. This typed no receiver. On the corpus, BalanceCustomerAccountService::handle() got no calls edge to ChargeCustomerService::handle(), while its two sibling callers (promoted param, typed method param) resolved fine.
Aggregate effect of the fixes
- Full corpus rebuild with the final fixes: 1,341 fabricated
calls edges removed, 2,398 legitimate calls edges added (46.6k-node rebuild).
- All three impact questions above now return all 3 grep-verified callers within the default token budget.
- 291 PHP type declarations now have real nodes.
explain, affected, query, and path agree on which node a name means.
Superseded PRs
| Old PR |
Content |
State |
| #2492 |
PHP member-call resolution from typed receivers (#1682) |
closed, replaced |
| #2502 |
use statement metadata; group-form use function/const fix |
closed, replaced |
| #2503 |
Cross-language isolation of resolvers and import edges |
closed, replaced |
| #2505 |
Member-call follow-ups: FCC, unions, qualified types, use-claim refusal |
closed, replaced |
| #2506 |
Declared-FQN binding with incremental parity |
closed, replaced |
| #2516 |
Query seeding: covered-term skip, filter relaxation (#2507) |
closed, replaced |
| #2536 |
Node identity: interface/trait/enum nodes, stub shadowing, relative scopes |
closed, replaced |
The replacement PR also contains fixes that were never proposed upstream, because they depended on the PRs above: the dangling-imports resolution (family 6), the constructor-body receiver (family 9), the function-call refusal (family 7), the interface/enum/trait receiver binding, and the scored-endpoint tie-break (family 6).
Fix
One PR follows this issue. It is based on the current v8 head (0 commits behind). Every fix carries tests. The full suite passes on the branch: 4,477 passed, 42 skipped. Full per-fix history is in the fork's tracking issue: lawnstarter/graphify#56.
Summary
This issue is a consolidated defect report for PHP support in graphify. It extends #1682 and adds every related defect we found while we fixed #1682. We wrote the fixes in our public fork, lawnstarter/graphify, and we validated them on a production Laravel codebase.
This issue supersedes our seven open PRs: #2492, #2502, #2503, #2505, #2506, #2516, #2536. Seven stacked PRs are hard to review and hard to keep current. We close all seven. One new PR replaces them and fixes this issue.
Test corpus
Almost all measurements below come from one codebase:
api.lawnstarter.com. It is a PHP 8.3 / Laravel monolith. Its graph has 46,406 nodes (48,500 nodes in a later snapshot, used for the query-seeding measurements). We call this "the corpus" below. All numbers are from real builds of this corpus, before and after each fix.Defect families
1. PHP member calls resolve to no
callsedge (#1682)The extractor emits
callsedges forClassName::method()only. It emits no edge for:$this->service->method()through a constructor-injected property. This is the dominant call pattern in Laravel code.$obj->method()on a typed local variable or a typed parameter.(new Service())->method().On the corpus,
LeadHunterServicehas grep-verified callers but zero inboundcallsedges. The graph looks complete but is not. This is the original report in #1682.2. The extractor discards
usestatement facts_import_phpkeeps only the trailing short name of ausestatement (raw.split("\\")[-1]). The written FQN, the alias, and the kind (class/function/const) are lost. Downstream passes then cannot know which class a file claims.Also, tree-sitter-php puts the
function/constkeyword in a different position for the group form. The result:use function A\{render};claimsrenderas a class name. Aclass I extends Renderin the same file then re-points itsinheritsedge to the wrong target.3. Member-call resolvers and import edges leak across languages
The receiver-type indexes were corpus-wide, not per-language. Two wrong results follow:
Lead.search({})call binds a Pythonclass Lead. This is a wrong cross-language edge, at EXTRACTED (1.0) confidence.Separately, an
importsedge dies when a same-stem foreign file sits beside its target (lead.mdbesidelead.py). Id disambiguation salts both file nodes, and the edge dangles. This affects Python, PowerShell, Zig, Rust, Pascal, Elixir, and Bash.4. Wrong or mislabeled member-call edges
$obj->method(...), creates aClosure. It does not invoke the method. It still got acallsedge.private Alpha|Beta $svc;) fell through to the in-file bare-name matcher. It bound whichever same-named method the file held, at EXTRACTED confidence.private (A&B)|C $x;) was not handled at all.\Vendor\Sdk\ClientbecameClient).use Vendor\Sdk\Client;claim was ignored. The resolver bound the one unrelated in-corpusClientinstead. Measured on the corpus: 3 wrong edges of this class.5. A claimed type name cannot bind to its declared FQN
When several in-corpus classes share the short name
X, a file withuse App\Alpha\X;refused instead of selectingApp\Alpha\X. A renaming alias,use App\Alpha\X as Y;, could never resolve, because nothing in the corpus is namedY. The bindings must also survivegraphify update: without persisted declared-FQN facts, every added edge vanishes on the first incremental rebuild.6. PHP node identity: interfaces, traits, and enums mint no node
_PHP_CONFIG.class_typesheldclass_declarationonly. On the corpus, 291 declarations had no node: 142 interfaces, 30 traits, 119 enums. The fan-in scattered onto sourceless stubs, per-file stubs, and file nodes. Consequences:graphify explainanswered from an arbitrary stub.graphify affectedrefused the same name with "No unique node match".importsedges dangled with no node behind them.parent::setUp()emitted a raw call with the literal callee nameparent. The cross-file pass then bound it to any->parent()accessor. Measured: 1,698 fabricated inboundcallsedges on one Eloquent model accessor.graphify queryandshortest_pathpreferred stubs on exact score ties.shortest_pathreturned a false "No directed path found" for a reachable pair.7. A PHP function call binds to a method or a class, cross-file
In PHP, a bare
name(...)call can only invoke a function. The shared cross-file pass matched by normalized label, which erases the member marker. So Laravel'sevent(...)helper bound to a test method namedevent(). Measured: 848 fabricated inboundcallsedges on one test method, from 368 distinct files, 308 of them not tests. The same family coveredconfig()(290 edges),dispatch()(186), andrequest()(15). The class half is real too:report($e)boundclass Checkr\Resources\Reportthrough the case-insensitive fold (7 edges).8. Natural-language query seeding fails on impact questions (follow-up to #2507)
We filed #2507. Upstream fixed one of the three reported failure modes in 0.9.35 (the relational-verb demotion). Two modes remain:
callcontext filter, but a class node owns no call edges. Measured: "callers of ChargeCustomerService" returns 1 node and 0 of its 3 grep-verified production callers, delivered confidently.9. A receiver typed by a constructor-body assignment resolves nothing
The pre-promotion Laravel idiom: an untyped property, a
@vardocblock, and a typed constructor parameter that the body assigns across. This typed no receiver. On the corpus,BalanceCustomerAccountService::handle()got nocallsedge toChargeCustomerService::handle(), while its two sibling callers (promoted param, typed method param) resolved fine.Aggregate effect of the fixes
callsedges removed, 2,398 legitimatecallsedges added (46.6k-node rebuild).explain,affected,query, andpathagree on which node a name means.Superseded PRs
usestatement metadata; group-formuse function/constfixThe replacement PR also contains fixes that were never proposed upstream, because they depended on the PRs above: the dangling-imports resolution (family 6), the constructor-body receiver (family 9), the function-call refusal (family 7), the interface/enum/trait receiver binding, and the scored-endpoint tie-break (family 6).
Fix
One PR follows this issue. It is based on the current
v8head (0 commits behind). Every fix carries tests. The full suite passes on the branch: 4,477 passed, 42 skipped. Full per-fix history is in the fork's tracking issue: lawnstarter/graphify#56.