Skip to content

PHP support, consolidated: member-call resolution, use-import handling, node identity, cross-language isolation, query seeding (superset of #1682) #2615

Description

@filipechagas

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions