From cf77a9734f8bd318a48fed88275fa4916147176c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 10 Feb 2026 18:21:55 +0100 Subject: [PATCH] Defer by-ref closure scope modifications after all args are processed processClosureNode returned a scope with by-ref captured variable modifications already applied. In processArgs, this polluted the scope used for evaluating subsequent arguments - but closures are not called during argument evaluation, so by-ref changes cannot have taken effect yet. The fix separates the by-ref data (closureResultScope, byRefUses) from the returned scope in ProcessClosureResult and lets callers decide when to apply it. The standalone closure call site applies immediately, while processArgs defers it until after all arguments are processed - same pattern as the existing deferral of processImmediatelyCalledCallable. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 11 +++++++++-- src/Analyser/ProcessClosureResult.php | 13 +++++++++++++ .../Rules/Methods/data/discussion-14038.php | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b9bf1942f6..4c62a3c9ac 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3493,7 +3493,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto } } elseif ($expr instanceof Expr\Closure) { $processClosureResult = $this->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); - $scope = $processClosureResult->getScope(); + $scope = $processClosureResult->applyByRefUseScope($processClosureResult->getScope()); return new ExpressionResult( $scope, @@ -5142,7 +5142,7 @@ private function processClosureNode( array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureScope, $storage); - return new ProcessClosureResult($scope->processClosureScope($closureResultScope, null, $byRefUses), $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $isAlwaysTerminating); + return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $isAlwaysTerminating, $closureResultScope, $byRefUses); } /** @@ -5551,6 +5551,8 @@ private function processArgs( $isAlwaysTerminating = false; /** @var list $deferredInvalidateExpressions */ $deferredInvalidateExpressions = []; + /** @var ProcessClosureResult[] $deferredByRefClosureResults */ + $deferredByRefClosureResults = []; foreach ($args as $i => $arg) { $assignByReference = false; $parameter = null; @@ -5662,6 +5664,7 @@ private function processArgs( } $scope = $closureResult->getScope(); + $deferredByRefClosureResults[] = $closureResult; $invalidateExpressions = $closureResult->getInvalidateExpressions(); if ($restoreThisScope !== null) { $nodeFinder = new NodeFinder(); @@ -5753,6 +5756,10 @@ private function processArgs( $scope = $this->processImmediatelyCalledCallable($scope, $invalidateExpressions, $uses); } + foreach ($deferredByRefClosureResults as $deferredClosureResult) { + $scope = $deferredClosureResult->applyByRefUseScope($scope); + } + if ($parameters !== null) { foreach ($args as $i => $arg) { $assignByReference = false; diff --git a/src/Analyser/ProcessClosureResult.php b/src/Analyser/ProcessClosureResult.php index 0d90fc1b66..dd6cfa5d26 100644 --- a/src/Analyser/ProcessClosureResult.php +++ b/src/Analyser/ProcessClosureResult.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use PhpParser\Node\ClosureUse; use PHPStan\Node\InvalidateExprNode; final class ProcessClosureResult @@ -11,6 +12,7 @@ final class ProcessClosureResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param InvalidateExprNode[] $invalidateExpressions + * @param ClosureUse[] $byRefUses */ public function __construct( private MutatingScope $scope, @@ -18,6 +20,8 @@ public function __construct( private array $impurePoints, private array $invalidateExpressions, private bool $isAlwaysTerminating, + private ?MutatingScope $byRefClosureResultScope = null, + private array $byRefUses = [], ) { } @@ -27,6 +31,15 @@ public function getScope(): MutatingScope return $this->scope; } + public function applyByRefUseScope(MutatingScope $scope): MutatingScope + { + if ($this->byRefClosureResultScope === null) { + return $scope; + } + + return $scope->processClosureScope($this->byRefClosureResultScope, null, $this->byRefUses); + } + /** * @return InternalThrowPoint[] */ diff --git a/tests/PHPStan/Rules/Methods/data/discussion-14038.php b/tests/PHPStan/Rules/Methods/data/discussion-14038.php index a0c73e05c2..12de156d37 100644 --- a/tests/PHPStan/Rules/Methods/data/discussion-14038.php +++ b/tests/PHPStan/Rules/Methods/data/discussion-14038.php @@ -30,3 +30,21 @@ function(): void { ); } } + +class ByRefTest +{ + private function foo(callable $c, string $s): void + { + } + + public function testByRef(): void + { + $x = 'hello'; + $this->foo( + function() use (&$x): void { + $x = 42; + }, + $x, + ); + } +}