[CodingStyle] Skip callee arity mismatch on ArrowFunctionDelegatingCallToFirstClassCallableRector - #8391
Merged
TomasVotruba merged 1 commit intoAug 28, 2026
Conversation
…llToFirstClassCallableRector Fixes a silent behavior change: the rule converts a 0-arity arrow function delegating to a method with an unused optional parameter into a first-class callable, which then forwards any extra argument the produced callable is invoked with — the original arrow function silently ignored it.
Member
|
LGTM 👍 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ArrowFunctionDelegatingCallToFirstClassCallableRector silently changes behavior when the callee has more parameters than the arrow function
Summary
ArrowFunctionDelegatingCallToFirstClassCallableRector(viaArrowFunctionAndClosureFirstClassCallableGuard::shouldSkip()) converts azero-argument arrow function delegating to a method into a first-class
callable reference, even when the target method declares an optional
parameter that the arrow function never had. When the resulting callable
is stored (e.g. as a
\Closuredefault value) and later invoked elsewherewith an argument, that argument — which the original arrow function would
have silently ignored (its arity was 0) — now flows into the method's
optional parameter. This is a silent behavior change, not just a style
rewrite.
Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRectorRector\CodingStyle\Guard\ArrowFunctionAndClosureFirstClassCallableGuard::shouldSkip()Reproduction
Rector rewrites the fallback to:
Before the rewrite,
$builder(['not', 'catalogue', 'items'])calls the0-arity arrow function, which ignores the argument and returns
$this->factory->build()(empty catalogue). After the rewrite, the samecall forwards
['not', 'catalogue', 'items']intobuild()'s$itemsparameter — a different, incorrect result. Nothing else in the surrounding
code changed; only the closure default was "simplified".
We hit this for real in application code: a test double defaulted to
fn (): Catalogue => $this->catalogue()as a fallback for a\Closurecollaborator; the collaborator is invoked elsewhere with oneargument. Rector's rewrite made that argument leak into
catalogue()'soptional
$itemsparameter, corrupting the built collection and breakingseveral tests with
Call to a member function getItem() on int.Root cause
In
ArrowFunctionAndClosureFirstClassCallableGuard::shouldSkip():The guard only compares:
call's argument count (both 0 in the repro — matches, so it doesn't
skip), and
threshold of 1 (
build(array $items = [])has exactly 1 parameter, so1 > 1isfalse— again doesn't skip).It never checks that the arrow function's own arity matches the callee's
arity. When the callee has one parameter the literal call never used
(picked up via a default value), converting to first-class-callable syntax
silently exposes that parameter to any external caller of the produced
callable — which is exactly what breaks here, since the callable is stored
and invoked elsewhere with a different argument count than the original
arrow function declared.
Suggested fix
Since
count($args) === count($params)is already guaranteed earlier inthe method, the final check should compare the callee's total parameter
count against that same number, instead of an unrelated fixed threshold.