Skip to content

PHP: app(X::class)->method() misresolves to the *enclosing* class's own method of the same name (wrong edge, not a missing edge) #76

Open

Description

@filipechagas

Found while validating the fork on providers-api.lawnstarter.com for PE-80051 (adoption epic).

Version: graphify 0.9.40, fork origin/v8 = 5e9d95c11837aeabbbb5d90cb45544f49dc58ca4.

Summary

The documented limit is that service-locator calls "never resolve" — no static tool can bind app(X::class). That would be acceptable: a missing edge is a known unknown.

What actually happens is worse. app(Handler::class)->handle() inside a class that itself has a handle() method produces a calls edge to its own handle(), not to Handler::handle(). The graph asserts a call that does not exist, and the real callee gets no incoming edge at all.

A consumer cannot detect this: the edge looks exactly like a legitimately resolved call.

Minimal reproduction

// src/Handler.php
<?php
namespace App;

class Handler
{
    public function handle(): string { return 'handled'; }
}
// src/Job.php
<?php
namespace App;

class Job
{
    public function handle(): string { return 'job-handle'; }

    public function failed(): string { return app(Handler::class)->handle(); }
}
$ graphify update .
$ python3 -c "…dump calls edges…"
.failed() src/Job.php -> .handle()  TARGET_SRC=src/Job.php L6  | at src/Job.php L8

The edge from Job::failed() (L8) points at Job::handle() (L6, same file) instead of Handler::handle(). Handler has no incoming calls edge whatsoever.

Expected: either no edge at all (honest refusal), or an indirect_call edge to Handler::handle().

Impact on the real repo

Reproduces in both Laravel job classes, in exactly the pattern Laravel encourages for failed() handlers:

Call site Real callee Edge graphify emitted
EvaluateAssessmentJob::failed() L46 — app(HandleFailedAssessmentEvaluation::class)->handle(...) HandleFailedAssessmentEvaluation::handle EvaluateAssessmentJob::handle() (L39, own class)
ScoreAssessmentAnswerJob::failed() L49 — app(HandleFailedAssessmentScoring::class)->handle(...) HandleFailedAssessmentScoring::handle ScoreAssessmentAnswerJob::handle() (L42, own class)

Neither HandleFailedAssessmentEvaluation nor HandleFailedAssessmentScoring has a single incoming calls edge in the graph — so "what breaks if I change the failure handler?" returns a false negative, while "what does failed() call?" returns a false positive pointing back into the job itself.

Suggested fix

When the receiver expression is a container call (app(...), resolve(...), App::make(...)), suppress method-name resolution against the enclosing class scope. If the ::class argument is a literal, an indirect_call edge to that class's method would be recoverable and strictly better; otherwise emit nothing.

Activity

  1. filipechagas commented on Aug 11, 2026

    @filipechagas
    CollaboratorAuthor

    Triage: pursue (high — wrong edge at EXTRACTED confidence, worse than missing). app(X::class)->m() binds to the enclosing class's own same-named method: simultaneously a false positive and a false negative on Laravel's canonical failed()-handler idiom. Lives in the member-call receiver-capture arm (engine.py:5382-5432/5634), NOT the scoped-call path of #73 — separate unit: defer on function_call_expression receivers (refuse), optionally an indirect_call from a literal ::class argument. No documented refusal covers container receivers today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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