Skip to content

ParametersAcceptorSelector: keep the acceptor-level variadic flag separate from the per-parameter one - #6460

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-37fm3rh
Sep 17, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-37fm3rh

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Calling a method on a union type where one member is variadic and the other is not (A::foo(int $a, int ...$rest) vs. B::foo(int $a)) reported a self-contradictory Method A::foo() invoked with 3 parameters, 1-2 required. — the combined signature still ended in int ...$rest, but the combined variant itself was marked non-variadic.

The same combined acceptor is used for every call form that goes through ParametersAcceptorSelector::combineAcceptors(), so the false positive showed up for method calls, static calls, first-class callables, callable arrays, call_user_func() and __invoke() alike.

Changes

  • src/Reflection/ParametersAcceptorSelector.php
    • combineAcceptors(): the per-parameter variadic flag moved from the shared $isVariadic variable into a new $isParameterVariadic, so merging a non-variadic acceptor no longer clears the accumulated acceptor-level flag.
    • combineAcceptors(): when the merged parameter at position i is variadic, the parameter list is truncated after it — the types of the dropped parameters (both the ones already collected and the remaining ones of the acceptor being merged) are now unioned into the variadic parameter's type, native type and PHPDoc type.
  • src/Reflection/Type/IntersectionTypeMethodReflection.php
    • getMethodWithMostParameters() now prefers a variadic variant over a non-variadic one, and only falls back to the parameter count when both have the same variadic-ness.

Call forms verified to be fixed by the combineAcceptors() change (each has a regression test that fails without it):

  • method call on a union — the reported case
  • static method call on a union
  • invoking a first-class callable created from a union method ($ab->foo(...))
  • invoking a callable array [$ab, 'foo']
  • call_user_func([$ab, 'foo'], …)
  • __invoke() on an intersection of object types (IntersectionType::getCallableParametersAcceptors())
  • a by-reference variadic parameter (int &...$rest) on a union

Probed and found already correct, so no test was kept for them: named arguments and argument unpacking on union method calls, Closure::fromCallable() on a union, array_map() with a union first-class callable, and intersections of callable PHPDoc types (TypeCombinator collapses those before they reach combineAcceptors()).

Root cause

combineAcceptors() builds one ExtendedFunctionVariant out of several ParametersAcceptors. Two different pieces of state were stored in the same $isVariadic variable:

  • before the parameter loop, $isVariadic = $isVariadic || $acceptor->isVariadic(); accumulated the variadic flag of the combined variant, which is what ends up in the returned ExtendedFunctionVariant/ExtendedCallableFunctionVariant;
  • inside the parameter loop, $isVariadic = $parameters[$i]->isVariadic() || $parameter->isVariadic(); described only the parameter at position i.

For A|B the first assignment set the flag to true for A::foo(int $a, int ...$rest), and merging B::foo(int $a) immediately overwrote it with false at position 0. ...$rest stayed in the parameter list while the variant claimed not to be variadic, which is why FunctionCallParametersCheck produced the contradictory 1-2 required message. The order of the union members did not matter; C::foo(int ...$rest) happened to work only because its variadic parameter sits at position 0, where the overwrite produces true by accident.

The second defect in the same function is a different symptom of the same "combining a variadic with a non-variadic acceptor" pattern: once a merged parameter is variadic, everything behind it is thrown away with array_slice(), and the discarded parameter types were lost. Combining foo(int ...$rest) with foo(int $a, string $b, string $c) produced foo(int ...) and reported Parameter #2 …$rest|a … expects int, string given, while the reverse union order produced a different — also wrong — result.

The intersection counterpart lives in IntersectionTypeMethodReflection, which picks a single representative method instead of combining them. Ranking purely by parameter count made IA&IB and IB&IA behave differently when one signature was variadic. A class that implements both IA::foo(int ...$rest) and IB::foo(int $a) has to accept any number of arguments, so the variadic signature is the correct representative.

Test

  • tests/PHPStan/Rules/Methods/data/bug-15251.php holds the reproducer from the issue plus one function per analogous call form (static call, first-class callable, callable array, call_user_func(), __invoke() on a union and on an intersection, by-reference variadic, and the dropped-parameter-types case). It is analysed by CallMethodsRuleTest::testBug15251(), CallStaticMethodsRuleTest::testBug15251(), CallCallablesRuleTest::testBug15251() and CallUserFuncRuleTest::testBug15251(); without the fix these report 14 arguments.count / argument.type errors between them, including the contradictory invoked with 0 parameters, 1-2 required.
  • tests/PHPStan/Analyser/nsrt/bug-15251.php pins the combined signature of $ab->foo(...) with assertType(), including that C|D and D|C infer the same Closure(int|string ...): void.

Fixes phpstan/phpstan#15251

…eparate from the per-parameter one

* `combineAcceptors()` used a single `$isVariadic` variable both for the variadic flag of the combined variant and for the variadic flag of the parameter currently being merged. Merging a non-variadic acceptor reset the accumulated flag, so the combined variant was returned as non-variadic even though its parameter list still ended in a variadic parameter. The per-parameter flag now lives in its own `$isParameterVariadic` variable.
* `combineAcceptors()` also dropped the types of the parameters behind the merged variadic parameter when truncating the parameter list. Their types (plus native and PHPDoc types) are now merged into the variadic parameter, which makes the combined signature independent of the order of the union members.
* `IntersectionTypeMethodReflection::getMethodWithMostParameters()` ranked variants by parameter count only, so `IA&IB` and `IB&IA` produced different signatures when one of the methods was variadic. A variadic variant now always wins over a non-variadic one.
* Probed and found already correct: named arguments and argument unpacking on union method calls, `Closure::fromCallable()` on unions, `array_map()` with a union first-class callable, and intersections of `callable` PHPDoc types (those collapse in `TypeCombinator`).
@ondrejmirtes
ondrejmirtes merged commit 857a5f4 into phpstan:2.2.x Sep 17, 2026
856 of 890 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-37fm3rh branch September 17, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants