Skip to content

Scope $this override to the Artisan::command() callback closure#1127

Merged
alies-dev merged 1 commit into
masterfrom
1114-followup-scope-self-to-callback
Jun 21, 2026
Merged

Scope $this override to the Artisan::command() callback closure#1127
alies-dev merged 1 commit into
masterfrom
1114-followup-scope-self-to-callback

Conversation

@alies-dev

Copy link
Copy Markdown
Collaborator

Issue to Solve

Post-merge follow-up to #1122 (closed #1114). A review of the merged ConsoleClosureScopeHandler found an Important bug: it set Context::$self to ClosureCommand for the whole Artisan::command(...) call, so self::/static:: in the signature argument resolved against ClosureCommand instead of the enclosing class:

class Provider extends ServiceProvider
{
    private const SIG = 'do:thing';
    public function boot(): void
    {
        // Before: self::SIG resolved to ClosureCommand::SIG → false UndefinedConstant
        Artisan::command(self::SIG, function (): void {
            $this->comment('x');
        });
    }
}

Related

Follow-up to #1122. Refs #1114.

Solution Description

Scope the self override to the callback closure node instead of the whole call:

  • beforeExpressionAnalysis records the spl_object_id of the Artisan::command() callback argument (2nd positional or named callback, non-static); when that exact closure node is analysed, it overrides Context::$self, and afterExpressionAnalysis restores it. Sibling arguments (the signature) keep the real self, so self::SIG / static::sig() resolve against the enclosing class.
  • The recorded id is not consumed on first match: the facade @method dispatch analyses the callback argument more than once (AtomicStaticCallAnalyzer::analyzePseudoMethodCall re-runs ArgumentsAnalyzer for the data-flow pass), so each pass must re-apply the override. The id set is reset per file (beforeAnalyzeFile) to bound it and prevent an spl_object_id from one file matching an unrelated closure in another.

Also addresses the review's suggestions: detection is restricted to the actual callback argument; the cheap AST checks run before the codebase queries; and the type tests are strengthened.

Verification (all empirical):

  • ConsoleClosureScopeSelfArgTest.phpt (new) is confirmed a true regression guard — it goes red on the pre-rewrite handler (UndefinedMagicMethod/InaccessibleMethod/MixedArgument on self::sig()/static::sig()) and green on this one. The reviewer's literal self::SIG const form was also probed directly and now reports no errors.
  • Strengthened fixtures: arrow + static fn forms, a named-argument callback, a non-global option name (so the unknown-signature path is actually exercised), and an arrow : ClosureCommand => $this return that proves the bound type rather than only "no issue".
  • composer test:type (509), composer test:unit (797), composer test:app (real routes/console.php), composer psalm (100% coverage), composer cs, composer rector all green.

Note for the reviewer: the literal repro used a class constant (self::SIG); the committed regression test uses a private static method (self::sig()/static::sig()) because a bare untyped const trips MissingClassConstType at errorLevel=1. Same mechanism, and the const form is verified fixed.

Checklist

  • Tests cover the change (type tests in tests/Type/)

Follow-up to #1122. That handler set Context::$self to ClosureCommand for
the whole Artisan::command() call, so self::/static:: in the *signature*
argument resolved against ClosureCommand — e.g. Artisan::command(self::SIG,
fn ...) inside a class reported a false UndefinedConstant/UndefinedMethod.

Override self only while the callback closure node itself is analysed
(record the callback arg's spl_object_id at the call, flip self when that
node is visited, restore after), leaving sibling arguments on the real
self. The callback id is not consumed on first match: the facade @method
dispatch analyses the argument more than once, so each pass re-applies the
override; the id set is reset per file to bound it and avoid spl_object_id
reuse across files.

Also restricts detection to the actual callback argument (2nd positional
or named `callback`), runs the cheap AST checks before codebase queries,
and strengthens the type tests (self::/static:: signature regression,
arrow and static-arrow forms, named-argument callback, non-global option).
@alies-dev alies-dev self-assigned this Jun 21, 2026
@alies-dev

Copy link
Copy Markdown
Collaborator Author

/psalm-delta

@alies-dev alies-dev added the release:fix for PRs only (used by release-drafter) label Jun 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR delta: Base (bca83dd) -> PR (7dceec4)

Per-app delta — Issues

No issue changes across the benchmarked apps.

No change (ran clean, zero delta): monica, pixelfed, coolify, bookstack, solidtime, laravel-socialite, laravel-permission, laravel-excel.

Per-app delta — Time (seconds)

No significant time change (all deltas within runner jitter).

Per-app delta — Type coverage (%)

No type-coverage changes.

Informational only — never fails the PR. Reproduce locally: bash bin/ci/delta.sh <pr-branch>.

@alies-dev alies-dev merged commit 8943f65 into master Jun 21, 2026
20 checks passed
@alies-dev alies-dev deleted the 1114-followup-scope-self-to-callback branch June 21, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:fix for PRs only (used by release-drafter)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

InvalidScope on $this in Artisan::command() closures (routes/console.php)

1 participant