From 6edef8f5d5c98d2ff28f0dc19c344b1f9c8c7ef5 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:12:59 +0000 Subject: [PATCH 1/2] Merge all callable acceptors' parameters into `NativeParameterReflection` when a closure is passed to a union of callables - `NodeScopeResolver::doCreateCallableParameters()` normalizes every acceptor's parameters into `NativeParameterReflection` up front, instead of only doing so for the first acceptor and for the ones it unions. A parameter that existed only in a later acceptor used to be stored raw (e.g. `ExtendedDummyParameter`, produced by generic/template resolution), so the next acceptor's merge called the `NativeParameterReflection`-only `union()` method on it and crashed the analysis with "Call to undefined method ...::union()". - The merge loop now walks `max(count($callableParameters), count($acceptorParameters))` indices instead of only the current acceptor's ones. Previously a shorter acceptor truncated the accumulated list, so the closure's inferred parameter types depended on the order of the union's members - the same closure got `string` or `mixed` for its second parameter depending on whether `callable(int): void|callable(int, string): void` or the reversed union was declared. This is what made the crash surface only after union types stopped being re-sorted in place. - Added `NativeParameterReflection::toOptional()`, used for parameters that only some of the merged signatures declare, mirroring how `ParametersAcceptorSelector::combineAcceptors()` marks parameters beyond the minimum arity as optional. - Both `createCallableParameters()` (PHPDoc types) and `createNativeCallableParameters()` (native types) go through the fixed method, and closures and arrow functions both use its result. - Probed the analogous paths: the `$args !== null` branch of the same method never calls `union()` (it only uses a single acceptor), intersections of callables already merged correctly, and `ParametersAcceptorSelector::combineAcceptors()` already keeps the longest parameter list - no changes needed there. --- src/Analyser/NodeScopeResolver.php | 38 ++++---- .../Native/NativeParameterReflection.php | 17 ++++ tests/PHPStan/Analyser/nsrt/bug-15003.php | 84 +++++++++++++++++ .../closure-passed-to-union-of-callables.php | 89 +++++++++++++++++++ 4 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15003.php create mode 100644 tests/PHPStan/Analyser/nsrt/closure-passed-to-union-of-callables.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 696104b2014..6e1ef776098 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -186,6 +186,7 @@ use function is_array; use function is_int; use function is_string; +use function max; use function sprintf; use function strtolower; use function trim; @@ -3301,33 +3302,34 @@ private function doCreateCallableParameters(Scope $scope, Expr $closureExpr, ?ar $acceptors = $passedToType->getCallableParametersAcceptors($scope); foreach ($acceptors as $acceptor) { + $acceptorParameters = array_map(static fn (ParameterReflection $callableParameter) => new NativeParameterReflection( + $callableParameter->getName(), + $callableParameter->isOptional(), + $callableParameter->getType(), + $callableParameter->passedByReference(), + $callableParameter->isVariadic(), + $callableParameter->getDefaultValue(), + ), $acceptor->getParameters()); + if ($callableParameters === null) { - $callableParameters = array_map(static fn (ParameterReflection $callableParameter) => new NativeParameterReflection( - $callableParameter->getName(), - $callableParameter->isOptional(), - $callableParameter->getType(), - $callableParameter->passedByReference(), - $callableParameter->isVariadic(), - $callableParameter->getDefaultValue(), - ), $acceptor->getParameters()); + $callableParameters = $acceptorParameters; continue; } $newParameters = []; - foreach ($acceptor->getParameters() as $i => $callableParameter) { + $parameterCount = max(count($callableParameters), count($acceptorParameters)); + for ($i = 0; $i < $parameterCount; $i++) { + if (!array_key_exists($i, $acceptorParameters)) { + $newParameters[] = $callableParameters[$i]->toOptional(); + continue; + } + if (!array_key_exists($i, $callableParameters)) { - $newParameters[] = $callableParameter; + $newParameters[] = $acceptorParameters[$i]->toOptional(); continue; } - $newParameters[] = $callableParameters[$i]->union(new NativeParameterReflection( - $callableParameter->getName(), - $callableParameter->isOptional(), - $callableParameter->getType(), - $callableParameter->passedByReference(), - $callableParameter->isVariadic(), - $callableParameter->getDefaultValue(), - )); + $newParameters[] = $callableParameters[$i]->union($acceptorParameters[$i]); } $callableParameters = $newParameters; diff --git a/src/Reflection/Native/NativeParameterReflection.php b/src/Reflection/Native/NativeParameterReflection.php index e8120868302..19da0a741a0 100644 --- a/src/Reflection/Native/NativeParameterReflection.php +++ b/src/Reflection/Native/NativeParameterReflection.php @@ -51,6 +51,23 @@ public function getDefaultValue(): ?Type return $this->defaultValue; } + /** Used when merging signatures where only some of them declare this parameter. */ + public function toOptional(): self + { + if ($this->optional) { + return $this; + } + + return new self( + $this->name, + true, + $this->type, + $this->passedByReference, + $this->variadic, + $this->defaultValue, + ); + } + public function union(self $other): self { return new self( diff --git a/tests/PHPStan/Analyser/nsrt/bug-15003.php b/tests/PHPStan/Analyser/nsrt/bug-15003.php new file mode 100644 index 00000000000..b412d462f30 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15003.php @@ -0,0 +1,84 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15003; + +use Closure; +use function PHPStan\Testing\assertType; + +/** + * @phpstan-type Foo InvokableClass|callable(string, mixed): int + */ +class TypeImportShortcut {} + +interface InvokableClass +{ + /** + * @param Closure(string, ?string=): string $fail + */ + public function __invoke(string $foo, Closure $fail): int; +} + +/** @phpstan-import-type Foo from TypeImportShortcut */ +class A +{ + + /** @param callable(string):Foo|Foo $param */ + public function foo($param): void {} + +} + +(new A)->foo(function(string $foo) { + assertType('string', $foo); + return 5; +}); + +interface InvokableRule +{ + + /** + * @param Closure(string): string $fail + */ + public function __invoke(string $attribute, mixed $value, Closure $fail); + +} + +/** + * @phpstan-type FieldValidationRule InvokableRule|(callable(string, mixed, Closure): void) + * @phpstan-type ValidationRules array|FieldValidationRule + */ +final class Field +{ + + /** + * @param (callable(string): ValidationRules)|ValidationRules $rules + */ + public function rules($rules): self + { + return $this; + } + + /** + * @param (callable(string): ValidationRules)|ValidationRules ...$rules + */ + public function creationRules($rules): self + { + return $this; + } + +} + +(new Field())->rules(function ($attribute, $value, $fail) { + assertType('string', $attribute); + assertType('mixed', $value); + assertType('Closure', $fail); +}); + +(new Field())->creationRules([ + function ($attribute, $value, $fail) { + assertType('mixed', $attribute); + assertType('mixed', $value); + assertType('mixed', $fail); + }, +]); diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-union-of-callables.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-union-of-callables.php new file mode 100644 index 00000000000..1cd77896227 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-union-of-callables.php @@ -0,0 +1,89 @@ +|callable(bool, float): void $cb + */ + public function withInvokable($cb): void + { + } + + /** + * @param callable(int...): void|callable(string, string): void $cb + */ + public function variadicFirst($cb): void + { + } + + /** + * @param callable(string, string): void|callable(int...): void $cb + */ + public function variadicLast($cb): void + { + } + + public function run(): void + { + $this->longestFirst(function ($a, $b): void { + assertType('int', $a); + assertType('string', $b); + }); + + $this->shortestFirst(function ($a, $b): void { + assertType('int', $a); + assertType('string', $b); + }); + + $this->longestFirst(fn ($a, $b) => assertType('int', $a)); + $this->longestFirst(fn ($a, $b) => assertType('string', $b)); + $this->shortestFirst(fn ($a, $b) => assertType('int', $a)); + $this->shortestFirst(fn ($a, $b) => assertType('string', $b)); + + $this->withInvokable(function ($a, $b): void { + assertType('bool|int|string', $a); + assertType('float|string', $b); + }); + + $this->variadicFirst(function ($a, $b): void { + assertType('int|string', $a); + assertType('string', $b); + }); + + $this->variadicLast(function ($a, $b): void { + assertType('int|string', $a); + assertType('string', $b); + }); + } + +} From 82999620bb3adee2b9a05cf5d8c9d055ece84764 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 28 Jul 2026 14:37:39 +0000 Subject: [PATCH 2/2] Move the #15003 crash reproducer to AnalyserIntegrationTest Co-Authored-By: Claude Opus 5 --- .../Analyser/AnalyserIntegrationTest.php | 8 ++++++ .../Analyser/{nsrt => data}/bug-15003.php | 28 +++++++++---------- 2 files changed, 21 insertions(+), 15 deletions(-) rename tests/PHPStan/Analyser/{nsrt => data}/bug-15003.php (74%) diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 06788c89415..18218040b39 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1608,6 +1608,14 @@ public function testBug14707(): void $this->assertNoErrors($errors); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15003(): void + { + // crash + $errors = $this->runAnalyse(__DIR__ . '/data/bug-15003.php'); + $this->assertNoErrors($errors); + } + /** * @param string[]|null $allAnalysedFiles * @return list diff --git a/tests/PHPStan/Analyser/nsrt/bug-15003.php b/tests/PHPStan/Analyser/data/bug-15003.php similarity index 74% rename from tests/PHPStan/Analyser/nsrt/bug-15003.php rename to tests/PHPStan/Analyser/data/bug-15003.php index b412d462f30..fcb93192b21 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15003.php +++ b/tests/PHPStan/Analyser/data/bug-15003.php @@ -5,7 +5,6 @@ namespace Bug15003; use Closure; -use function PHPStan\Testing\assertType; /** * @phpstan-type Foo InvokableClass|callable(string, mixed): int @@ -30,7 +29,6 @@ public function foo($param): void {} } (new A)->foo(function(string $foo) { - assertType('string', $foo); return 5; }); @@ -40,7 +38,7 @@ interface InvokableRule /** * @param Closure(string): string $fail */ - public function __invoke(string $attribute, mixed $value, Closure $fail); + public function __invoke(string $attribute, mixed $value, Closure $fail): void; } @@ -69,16 +67,16 @@ public function creationRules($rules): self } -(new Field())->rules(function ($attribute, $value, $fail) { - assertType('string', $attribute); - assertType('mixed', $value); - assertType('Closure', $fail); -}); +function rules(): Field +{ + return (new Field())->rules(function ($attribute, $value, $fail) { + }); +} -(new Field())->creationRules([ - function ($attribute, $value, $fail) { - assertType('mixed', $attribute); - assertType('mixed', $value); - assertType('mixed', $fail); - }, -]); +function creationRules(): Field +{ + return (new Field())->creationRules([ + function ($attribute, $value, $fail) { + }, + ]); +}