From 9cdf2496102df5cf0e841addd7b1c5e06f4120af Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:17:00 +0000 Subject: [PATCH] Track by-reference value modifications in constant array foreach unrolling - Enable `tryProcessUnrolledConstantArrayForeach` to handle by-ref foreach by removing the early `$stmt->byRef` bail-out and propagating value variable changes back to the array after each unrolled iteration via `setExistingOffsetValueType` - Remove `isConstantArray()->no()` guard in `MutatingScope::enterForeach` so constant arrays also get `IntertwinedVariableByReferenceWithExpr` tracking as a fallback for arrays exceeding the unroll limit - Add regression tests covering: constant array by-ref cast, by-ref with key variable, shaped array by-ref, list type preservation, conditional modification, compound value replacement, and for-loop baseline --- src/Analyser/MutatingScope.php | 4 +- src/Analyser/NodeScopeResolver.php | 29 ++++++- tests/PHPStan/Analyser/nsrt/bug-1311.php | 105 +++++++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-1311.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 79d4be017bf..2ba8837bd15 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2359,7 +2359,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN // corresponding array dim fetch without being confused by a reassignment // ($type = 'foo' invalidates this expression, same as OriginalForeachKeyExpr). $scope = $scope->assignExpression(new OriginalForeachValueExpr($valueName), $valueType, $nativeValueType); - if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { + if ($valueByRef && $iterateeType->isArray()->yes()) { $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetExistingOffsetValueTypeExpr( $iteratee, @@ -2373,7 +2373,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN if ($keyName !== null) { $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); - if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { + if ($valueByRef && $iterateeType->isArray()->yes()) { $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, new Expr\ArrayDimFetch($iteratee, new Variable($keyName)), new Variable($valueName)), $valueType, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 89a65fca313..ca6bf0afc3b 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3885,9 +3885,6 @@ private function tryProcessUnrolledConstantArrayForeach( StatementContext $context, ): ?array { - if ($stmt->byRef) { - return null; - } if (!($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name))) { return null; } @@ -3982,6 +3979,32 @@ private function tryProcessUnrolledConstantArrayForeach( $breakScopes[] = $breakExitPoint->getScope(); } + if ($stmt->byRef) { + $newValueType = $iterEndScope->getType(new Variable($valueVarName)); + $newNativeValueType = $iterEndScope->getNativeType(new Variable($valueVarName)); + + $currentArrayType = $iterEndScope->getType($stmt->expr); + $currentNativeArrayType = $iterEndScope->getNativeType($stmt->expr); + + $newArrayType = $currentArrayType->setExistingOffsetValueType($keyType, $newValueType); + $newNativeArrayType = $currentNativeArrayType->setExistingOffsetValueType($nativeKeyType, $newNativeValueType); + + if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { + $iterEndScope = $iterEndScope->assignVariable( + $stmt->expr->name, + $newArrayType, + $newNativeArrayType, + TrinaryLogic::createYes(), + ); + } else { + $iterEndScope = $iterEndScope->assignExpression( + $stmt->expr, + $newArrayType, + $newNativeArrayType, + ); + } + } + if ($isOptional) { $chainScope = $iterEndScope->mergeWith($chainScope); } else { diff --git a/tests/PHPStan/Analyser/nsrt/bug-1311.php b/tests/PHPStan/Analyser/nsrt/bug-1311.php new file mode 100644 index 00000000000..c40e8802153 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-1311.php @@ -0,0 +1,105 @@ + $sets + * + * @return array + */ + public function sayHello(array $sets): array + { + foreach ($sets as &$set) { + $set['b'] = false; + } + + assertType('array', $sets); + + return $sets; + } +} + +function foreachByRefConstantArray(): void +{ + $temp = [1, 2, 3]; + + foreach ($temp as &$item) { + $item = (string) $item; + } + + assertType("array{'1', '2', '3'}", $temp); +} + +function foreachByRefConstantArrayWithKey(): void +{ + $temp = [1, 2, 3]; + + foreach ($temp as $key => &$item) { + $item = (string) $item; + } + + assertType("array{'1', '2', '3'}", $temp); +} + +function foreachByRefShapedArray(): void +{ + /** @var array{a: int, b: int} $data */ + $data = ['a' => 1, 'b' => 2]; + + foreach ($data as &$val) { + $val = (string) $val; + } + + assertType("array{a: lowercase-string&numeric-string&uppercase-string, b: lowercase-string&numeric-string&uppercase-string}", $data); +} + +function foreachByRefListPreservation(): void +{ + /** @var list $list */ + $list = [1, 2, 3]; + + foreach ($list as &$item) { + $item = $item * 2; + } + + assertType("list", $list); +} + +function foreachByRefConditionalModification(): void +{ + $temp = [1, 2, 3]; + + foreach ($temp as &$item) { + if ($item > 1) { + $item = (string) $item; + } + } + + assertType("array{1, '2', '3'}", $temp); +} + +function foreachByRefAppend(): void +{ + $data = ['a' => 1, 'b' => 2]; + + foreach ($data as &$val) { + $val = [$val, 'extra']; + } + + assertType("array{a: array{1, 'extra'}, b: array{2, 'extra'}}", $data); +} + +function forLoop(): void +{ + $temp = [1, 2, 3]; + + for ($i = 0; $i < count($temp); $i++) { + $temp[$i] = (string) $temp[$i]; + } + + assertType("array{1|(literal-string&lowercase-string&non-falsy-string&numeric-string&uppercase-string), 2|(literal-string&lowercase-string&non-falsy-string&numeric-string&uppercase-string), 3|(literal-string&lowercase-string&non-falsy-string&numeric-string&uppercase-string)}", $temp); +}