From ef0221ac012c6a7c56fcd2ef3bf8d89702980e31 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Fri, 23 Jan 2026 14:54:10 +0100 Subject: [PATCH 1/2] Fix closure type inference in FiberScope FiberScope::pushInFunctionCall() and popInFunctionCall() were no-ops, which prevented the inFunctionCallsStack from being populated when using FNSR (Fiber Node Scope Resolver). This caused closure parameter types to not be properly inferred from the expected callable type when a closure is passed as an argument. The fix implements proper function call stack tracking in FiberScope and removes the FiberScope exclusion in ParametersAcceptorSelector. Fixes #13993 Co-Authored-By: Claude Opus 4.5 --- src/Analyser/Fiber/FiberScope.php | 59 +++++++- src/Reflection/ParametersAcceptorSelector.php | 5 +- .../closure-passed-to-type-fiberscope.php | 133 ++++++++++++++++++ 3 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index bd465a40fa..8b14b4a877 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -10,6 +10,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Type\Type; +use function array_pop; final class FiberScope extends MutatingScope { @@ -140,14 +141,64 @@ private function preprocessScope(MutatingScope $scope): Scope */ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, bool $rememberTypes): self { - // no need to track this in rules, the type will be correct anyway - return $this; + // Track the function call stack so closure types can be properly inferred + // when the closure is passed as a callable argument + $stack = $this->inFunctionCallsStack; + $stack[] = [$reflection, $parameter]; + + /** @var self $scope */ + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->getAnonymousFunctionReflection(), + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + parent::getParentScope(), + $this->nativeTypesPromoted, + ); + $scope->truthyValueExprs = $this->truthyValueExprs; + $scope->falseyValueExprs = $this->falseyValueExprs; + + return $scope; } public function popInFunctionCall(): self { - // no need to track this in rules, the type will be correct anyway - return $this; + $stack = $this->inFunctionCallsStack; + array_pop($stack); + + /** @var self $scope */ + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->getAnonymousFunctionReflection(), + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + parent::getParentScope(), + $this->nativeTypesPromoted, + ); + $scope->truthyValueExprs = $this->truthyValueExprs; + $scope->falseyValueExprs = $this->falseyValueExprs; + + return $scope; } public function getParentScope(): ?MutatingScope diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index fb8d94671a..596dcc4be0 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -5,7 +5,6 @@ use Closure; use PhpParser\Node; use PHPStan\Analyser\ArgumentsNormalizer; -use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; @@ -498,14 +497,14 @@ public static function selectFromArgs( } } - if ($parameter !== null && $scope instanceof MutatingScope && !$scope instanceof FiberScope) { + if ($parameter !== null && $scope instanceof MutatingScope) { $rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction; $scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes); } $type = $scope->getType($originalArg->value); - if ($parameter !== null && $scope instanceof MutatingScope && !$scope instanceof FiberScope) { + if ($parameter !== null && $scope instanceof MutatingScope) { $scope = $scope->popInFunctionCall(); } diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php new file mode 100644 index 0000000000..72fa5ab067 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php @@ -0,0 +1,133 @@ +}>): iterable $loader + */ + public function __construct( + private Closure $loader, + ) {} +} + +/** + * Test: Closure parameter inference with array destructuring in constructor + * When a closure is passed to a constructor, the parameter types should be + * inferred from the expected Closure type, including array destructuring. + */ +function testClosureParameterInferenceWithDestructuring(): void +{ + $loader = new Loader( + loader: function (Context $context, array $items): iterable { + assertType('non-empty-array}>', $items); + foreach ($items as [$dateRange, $ids]) { + assertType('ClosurePassedToTypeFiberScope\DateRange', $dateRange); + assertType('list', $ids); + foreach ($ids as $id) { + assertType('int', $id); + yield [$id, $dateRange->format()] => 'value'; + } + } + }, + ); +} + +// ============================================================================ +// Example 2: Generic callable parameter resolution +// ============================================================================ + +/** + * @template T + */ +class Vote +{ + /** + * @param T $subject + */ + public function __construct( + public bool $granted, + public mixed $subject, + ) {} +} + +/** + * @template TSubject + */ +class Decision +{ + /** + * @param list> $votes + */ + public function __construct( + private array $votes, + ) {} + + /** + * @template U + * @template K of array-key + * + * @param callable(Vote $vote): iterable $fn + * + * @return array + */ + public function collect(callable $fn): array + { + $result = []; + foreach ($this->votes as $vote) { + foreach ($fn($vote) as $key => $value) { + $result[$key] = $value; + } + } + return $result; + } +} + +class Subject +{ + public function id(): int + { + return 42; + } +} + +/** + * Test: Generic callable parameter resolution + * When passing a closure to Decision::collect(), + * the Vote parameter should be inferred as Vote. + */ +function testGenericCallableParameterResolution(): void +{ + $decision = new Decision([new Vote(granted: true, subject: new Subject())]); + $result = $decision->collect(static function (Vote $vote): iterable { + assertType('ClosurePassedToTypeFiberScope\Vote', $vote); + assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject); + if ($vote->granted) { + yield $vote->subject->id() => $vote->subject; + } + }); + assertType('array', $result); +} From 5038ad06b9189771e3d6e50182c8af6eda8a8e6e Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Fri, 23 Jan 2026 15:50:30 +0100 Subject: [PATCH 2/2] Require PHP 8.1+ for FiberScope regression test The test uses PHP 8.1+ features (Fibers) and tests FiberScope-specific behavior. Add // lint >= 8.1 comment to skip the test on PHP 7.4 and 8.0. Co-Authored-By: Claude Opus 4.5 --- ...osure-passed-to-type-fiberscope-php81.php} | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) rename tests/PHPStan/Analyser/nsrt/{closure-passed-to-type-fiberscope.php => closure-passed-to-type-fiberscope-php81.php} (65%) diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php similarity index 65% rename from tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php rename to tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php index 72fa5ab067..0978519fe0 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope.php +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php @@ -1,4 +1,4 @@ -= 8.1 namespace ClosurePassedToTypeFiberScope; @@ -8,6 +8,10 @@ /** * Regression tests for closure parameter type inference in FiberScope. * @see https://github.com/phpstan/phpstan/issues/13993 + * + * These tests verify that closure parameter types are properly inferred from + * expected callable types when using FiberScope. Since FiberScope requires + * PHP 8.1+ (Fibers), this file requires PHP 8.1+. */ // ============================================================================ @@ -39,22 +43,19 @@ public function __construct( * When a closure is passed to a constructor, the parameter types should be * inferred from the expected Closure type, including array destructuring. */ -function testClosureParameterInferenceWithDestructuring(): void -{ - $loader = new Loader( - loader: function (Context $context, array $items): iterable { - assertType('non-empty-array}>', $items); - foreach ($items as [$dateRange, $ids]) { - assertType('ClosurePassedToTypeFiberScope\DateRange', $dateRange); - assertType('list', $ids); - foreach ($ids as $id) { - assertType('int', $id); - yield [$id, $dateRange->format()] => 'value'; - } +$loader = new Loader( + loader: function (Context $context, array $items): iterable { + assertType('non-empty-array}>', $items); + foreach ($items as [$dateRange, $ids]) { + assertType('ClosurePassedToTypeFiberScope\DateRange', $dateRange); + assertType('list', $ids); + foreach ($ids as $id) { + assertType('int', $id); + yield [$id, $dateRange->format()] => 'value'; } - }, - ); -} + } + }, +); // ============================================================================ // Example 2: Generic callable parameter resolution @@ -119,15 +120,12 @@ public function id(): int * When passing a closure to Decision::collect(), * the Vote parameter should be inferred as Vote. */ -function testGenericCallableParameterResolution(): void -{ - $decision = new Decision([new Vote(granted: true, subject: new Subject())]); - $result = $decision->collect(static function (Vote $vote): iterable { - assertType('ClosurePassedToTypeFiberScope\Vote', $vote); - assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject); - if ($vote->granted) { - yield $vote->subject->id() => $vote->subject; - } - }); - assertType('array', $result); -} +$decision = new Decision([new Vote(granted: true, subject: new Subject())]); +$result = $decision->collect(static function (Vote $vote): iterable { + assertType('ClosurePassedToTypeFiberScope\Vote', $vote); + assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject); + if ($vote->granted) { + yield $vote->subject->id() => $vote->subject; + } +}); +assertType('array', $result);