From fad35b7afc10d42b7f2a0d415eef53d3ce91e6cd Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:17:57 +0000 Subject: [PATCH] Fix by-ref variable type not propagated between closure arguments - When multiple closures sharing a by-ref variable are passed as arguments to the same function call, subsequent closures now see by-ref modifications from earlier closures - Applied deferred by-ref closure results to the scope used for closure body analysis without leaking to non-closure argument evaluation - New regression test in tests/PHPStan/Analyser/nsrt/bug-14096.php Closes https://github.com/phpstan/phpstan/issues/14096 --- src/Analyser/NodeScopeResolver.php | 8 +++- tests/PHPStan/Analyser/nsrt/bug-14096.php | 56 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14096.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 79b05cd0f8..ac1c9b80ea 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3330,7 +3330,11 @@ public function processArgs( } $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null); + $closureScopeToPass = $scopeToPass; + foreach ($deferredByRefClosureResults as $deferredClosureResult) { + $closureScopeToPass = $deferredClosureResult->applyByRefUseScope($closureScopeToPass); + } + $closureResult = $this->processClosureNode($stmt, $arg->value, $closureScopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); @@ -3348,7 +3352,7 @@ public function processArgs( $uses[] = $use->var->name; } - $scope = $closureResult->getScope(); + $scope = $scopeToPass; $deferredByRefClosureResults[] = $closureResult; $invalidateExpressions = $closureResult->getInvalidateExpressions(); if ($restoreThisScope !== null) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14096.php b/tests/PHPStan/Analyser/nsrt/bug-14096.php new file mode 100644 index 0000000000..99883cb14c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14096.php @@ -0,0 +1,56 @@ +simulateAppCallback(static function (ServerRequestInterface $request) use ($createViewFx, &$view) { + $view = $createViewFx($request); + + return new App(); + }, static function () use ($simulateRequestFx, &$view) { + assertType('T of Bug14096\AbstractView (method Bug14096\Test::simulateViewCallback(), argument)|null', $view); + + return $simulateRequestFx($view); + }); + + assertType('T of Bug14096\AbstractView (method Bug14096\Test::simulateViewCallback(), argument)|null', $view); + + return $view; + } +}