From 77a5c310f915de4b34569b8cd56ffb7692fedffc Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:07:01 +0000 Subject: [PATCH] Preserve array shape when building array in foreach over constant array - Added post-loop refinement that unrolls the foreach body element-by-element for constant arrays to reconstruct precise array shapes - New method refineForEachScopeForConstantArray in NodeScopeResolver processes each constant array element individually with specific key/value types - New method refineTypesFromConstantArrayForeach in MutatingScope replaces generalized array types with the precise constant array types from the unrolled processing when they are more specific - Updated bug-8924 test expectation to reflect improved precision - New regression test in tests/PHPStan/Analyser/nsrt/bug-13000.php Closes https://github.com/phpstan/phpstan/issues/13000 --- src/Analyser/MutatingScope.php | 75 ++++++++++++++++ src/Analyser/NodeScopeResolver.php | 104 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13000.php | 69 ++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-8924.php | 2 +- 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13000.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index db31ce7669..5df8d86c9f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4388,6 +4388,81 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope ); } + public function refineTypesFromConstantArrayForeach(self $unrolledScope): self + { + $expressionTypes = $this->expressionTypes; + foreach ($unrolledScope->expressionTypes as $exprString => $unrolledHolder) { + if (!isset($expressionTypes[$exprString])) { + continue; + } + + $currentHolder = $expressionTypes[$exprString]; + $unrolledType = $unrolledHolder->getType(); + $currentType = $currentHolder->getType(); + + // Only refine if the unrolled scope has a more precise type + if ( + !$unrolledType->isConstantArray()->yes() + || !$currentType->isConstantArray()->no() + || !$currentType->isArray()->yes() + || !$currentType->isSuperTypeOf($unrolledType)->yes() + ) { + continue; + } + + $expressionTypes[$exprString] = new ExpressionTypeHolder( + $currentHolder->getExpr(), + $unrolledType, + $currentHolder->getCertainty(), + ); + } + + $nativeTypes = $this->nativeExpressionTypes; + foreach ($unrolledScope->nativeExpressionTypes as $exprString => $unrolledHolder) { + if (!isset($nativeTypes[$exprString])) { + continue; + } + + $currentHolder = $nativeTypes[$exprString]; + $unrolledType = $unrolledHolder->getType(); + $currentType = $currentHolder->getType(); + + if ( + !$unrolledType->isConstantArray()->yes() + || !$currentType->isConstantArray()->no() + || !$currentType->isArray()->yes() + || !$currentType->isSuperTypeOf($unrolledType)->yes() + ) { + continue; + } + + $nativeTypes[$exprString] = new ExpressionTypeHolder( + $currentHolder->getExpr(), + $unrolledType, + $currentHolder->getCertainty(), + ); + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + public function generalizeWith(self $otherScope): self { $variableTypeHolders = $this->generalizeVariableTypeHolders( diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8a807ea745..f262e7f3bd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1347,6 +1347,10 @@ private function processStmtNode( $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); } + if ($context->isTopLevel()) { + $finalScope = $this->refineForEachScopeForConstantArray($finalScope, $scope, $originalStorage, $stmt, $context, $breakExitPoints, $arrayComparisonExpr); + } + $exprType = $scope->getType($stmt->expr); $hasExpr = $scope->hasExpressionType($stmt->expr); if ( @@ -7078,6 +7082,106 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto return $this->processVarAnnotation($scope, $vars, $stmt); } + /** + * @param InternalStatementExitPoint[] $breakExitPoints + */ + private function refineForEachScopeForConstantArray( + MutatingScope $finalScope, + MutatingScope $outerScope, + ExpressionResultStorage $originalStorage, + Foreach_ $stmt, + StatementContext $context, + array $breakExitPoints, + Expr $arrayComparisonExpr, + ): MutatingScope + { + if (count($breakExitPoints) > 0) { + return $finalScope; + } + + if ($stmt->byRef) { + return $finalScope; + } + + if ($stmt->getDocComment() !== null) { + return $finalScope; + } + + if (!$stmt->valueVar instanceof Variable || !is_string($stmt->valueVar->name)) { + return $finalScope; + } + + if (!$stmt->keyVar instanceof Variable || !is_string($stmt->keyVar->name)) { + return $finalScope; + } + + $iterateeType = $outerScope->getType($stmt->expr); + $nativeIterateeType = $outerScope->getNativeType($stmt->expr); + $constantArrays = $iterateeType->getConstantArrays(); + $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); + if ( + !$iterateeType->isConstantArray()->yes() + || count($constantArrays) !== 1 + || !$iterateeType->isIterableAtLeastOnce()->yes() + ) { + return $finalScope; + } + + $constantArray = $constantArrays[0]; + $nativeConstantArray = count($nativeConstantArrays) === 1 ? $nativeConstantArrays[0] : null; + + $keyTypes = $constantArray->getKeyTypes(); + if (count($keyTypes) === 0) { + return $finalScope; + } + + // Process the loop body element-by-element with specific key/value types + $unrolledScope = $this->polluteScopeWithAlwaysIterableForeach ? $outerScope->filterByTruthyValue($arrayComparisonExpr) : $outerScope; + foreach ($keyTypes as $i => $keyType) { + $valueType = $constantArray->getValueTypes()[$i]; + $nativeKeyType = $nativeConstantArray !== null ? $nativeConstantArray->getKeyTypes()[$i] : $keyType; + $nativeValueType = $nativeConstantArray !== null ? $nativeConstantArray->getValueTypes()[$i] : $valueType; + + $elementScope = $unrolledScope->assignVariable( + $stmt->keyVar->name, + $keyType, + $nativeKeyType, + TrinaryLogic::createYes(), + ); + $elementScope = $elementScope->assignVariable( + $stmt->valueVar->name, + $valueType, + $nativeValueType, + TrinaryLogic::createYes(), + ); + + $elementStorage = $originalStorage->duplicate(); + $elementResult = $this->processStmtNodesInternal( + $stmt, + $stmt->stmts, + $elementScope, + $elementStorage, + new NoopNodeCallback(), + $context->enterDeep(), + )->filterOutLoopExitPoints(); + + if (count($elementResult->getExitPointsByType(Break_::class)) > 0) { + return $finalScope; + } + + if ($elementResult->isAlwaysTerminating()) { + return $finalScope; + } + + $unrolledScope = $elementResult->getScope(); + foreach ($elementResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $unrolledScope = $continueExitPoint->getScope()->mergeWith($unrolledScope); + } + } + + return $finalScope->refineTypesFromConstantArrayForeach($unrolledScope); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-13000.php b/tests/PHPStan/Analyser/nsrt/bug-13000.php new file mode 100644 index 0000000000..8099487641 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13000.php @@ -0,0 +1,69 @@ + '1', 'b' => '2'] as $key => $val) { + $r[$key] = $val; + } + assertType("array{a: '1', b: '2'}", $r); +} + +function constantArrayForeachWithTransform(): void +{ + $r = []; + foreach (['a' => 'hello', 'b' => 'world'] as $key => $val) { + $r[$key] = strtoupper($val); + } + assertType("array{a: 'HELLO', b: 'WORLD'}", $r); +} + +/** + * @param array{a: string, b: string} $input + */ +function constantArrayForeachFromParam(array $input): void +{ + $r = []; + foreach ($input as $key => $val) { + $r[$key] = strtoupper($val); + } + assertType("array{a: uppercase-string, b: uppercase-string}", $r); +} + +/** + * @return array{a: string, b: string} + */ +function returnTypeIsCompatible(): array +{ + $r = []; + foreach (['a' => '1', 'b' => '2'] as $key => $val) { + $r[$key] = $val; + } + assertType("array{a: '1', b: '2'}", $r); + return $r; +} + +function integerKeys(): void +{ + $r = []; + foreach ([10 => 'x', 20 => 'y'] as $key => $val) { + $r[$key] = $val; + } + assertType("array{10: 'x', 20: 'y'}", $r); +} + +/** + * @param array{x: int, y: int, z: int} $coords + */ +function threeKeys(array $coords): void +{ + $r = []; + foreach ($coords as $key => $val) { + $r[$key] = $val * 2; + } + assertType("array{x: int, y: int, z: int}", $r); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-8924.php b/tests/PHPStan/Analyser/nsrt/bug-8924.php index ccb3ccdf45..af6194d4f3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8924.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8924.php @@ -26,7 +26,7 @@ function makeValidNumbers(): array assertType("non-empty-list<-2|-1|1|2|' 1'|' 2'>", $validNumbers); } - assertType("non-empty-list<-2|-1|1|2|' 1'|' 2'>", $validNumbers); + assertType("array{1, 2, -1, ' 1', -2, ' 2'}", $validNumbers); return $validNumbers; }