Skip to content

PHP: relative-scope calls (parent::/self::/static::) resolve to nothing — needs a base-class-aware resolution pass (#39 follow-up) #58

Description

@filipechagas

Split out from the review of upstream Graphify-Labs#2536, where an automated reviewer objected that #39's refusal drops information (thread). The objection to the proposed alternative was declined with measurements (below), but the underlying gap it points at is real and currently untracked.

Current behaviour (correct, but incomplete)

#39 / 075038e refuses parent, self and static as PHP scoped-call callee names (graphify/extractors/engine.py:4382-4394, gated on _PHP_RELATIVE_SCOPE_NAMES). Before it, the handler took the scope text as the callee, so every parent::setUp() emitted a raw call named parent, and the cross-file label pass bound them all to whatever ->parent() accessor existed — 1,698 fabricated calls edges onto ServiceCategory::parent() on the pinned 46.4k-node corpus.

The refusal removes the fabricated edges and invents no replacement. So parent::__construct(), parent::setUp(), self::helper() and static::make() currently produce no calls edge at all, with no diagnostic counter.

Why the obvious alternative does not work

Emitting the method name as an unqualified callee instead — the reviewer's suggestion — was implemented and measured against a PHPUnit-shaped corpus (base class out of corpus, one unrelated in-corpus setUp):

  • baseline: 0 edges
  • unqualified-callee variant: 1 fabricated INFERRED edge, FooTest::prepare()App\Models\ServiceCategory::setUp()

The raw call carries is_member_call=False, so it is skipped by neither the member-call guard nor the typed resolver, and reaches the shared normalized-label loop whose index keys .setUp() and setUp() identically → sole candidate → unconditional bind. #52's candidate filter does not cover it (verified by building a tree with #52 applied — still 1 edge): that filter is gated on the php_function_call marker, and #52's own comment explains it cannot be widened to scoped calls, because a scoped_call is also not a member call.

What the real fix needs

The rc_entry built for a raw call carries caller, callee, receiver, receiver type and language — no enclosing class and no base class. The information that would answer "which class does parent denote here" exists in the graph as inherits edges (minted by _php_emit_base), but the shared bare-label loop never consults them.

So a correct fix is a resolution-pass change, not an extractor tweak:

  1. Carry the enclosing class on the raw call (or recover it from caller_nid).
  2. For a relative scope, resolve it against the caller's own inherits edge — parent → the resolved base class, self/static → the enclosing class itself (late static binding means static is strictly a guess for anything but the declaring class; refusing static while resolving parent/self is a defensible first cut).
  3. Look the method up on that resolved type, and refuse when the base class is out of corpus (the PHPUnit case) rather than falling back to a name match.

Point 3 is the load-bearing one: an out-of-corpus base class must yield nothing, or this reintroduces exactly the fabricated-edge shape it replaces.

Notes

  • Sequencing: composes with #52's candidate-filtering, which lives on fix/resolution-gaps-52-53-54. Worth landing after that branch merges to v8.
  • Extraction-side, so it will need a version bump to roll the AST cache namespace.
  • Blast radius if fixed: parent::__construct() and parent::setUp() are ubiquitous in a Laravel/PHPUnit corpus, so this is recall left on the table, not a correctness bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions