From 358fc3f167d18774ea0a164f191b4c477e1f2066 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:52:48 +0000 Subject: [PATCH] Unroll foreach over union of constant arrays in `tryProcessUnrolledConstantArrayForeach` - Support iterating over a union of multiple ConstantArrayType values (e.g. `list{'a','b'}|list{'x','y'}`) in the foreach unrolling logic - Previously, unrolling was restricted to exactly one constant array (`count($constantArrays) !== 1`), causing unions to fall back to the imprecise iterative processing that merged all possible keys - Now each constant array in the union is unrolled independently with its own chain scope, and the results are merged across arrays - This preserves per-array type precision when building arrays inside foreach loops over constant array unions Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 182 +++++++++++++---------- tests/PHPStan/Analyser/nsrt/bug-7978.php | 57 +++++++ 2 files changed, 162 insertions(+), 77 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7978.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 89a65fca313..dd38f798f2a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3900,113 +3900,141 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } $constantArrays = $iterateeType->getConstantArrays(); - if (count($constantArrays) !== 1) { + if (count($constantArrays) === 0) { return null; } - $constantArray = $constantArrays[0]; - $keyTypes = $constantArray->getKeyTypes(); - $valueTypes = $constantArray->getValueTypes(); - if (count($keyTypes) === 0 || count($keyTypes) > self::FOREACH_UNROLL_LIMIT) { + + $totalKeys = 0; + foreach ($constantArrays as $constantArray) { + $totalKeys += count($constantArray->getKeyTypes()); + } + if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; } $nativeIterateeType = $originalScope->getNativeType($stmt->expr); $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); - $nativeConstantArray = count($nativeConstantArrays) === 1 ? $nativeConstantArrays[0] : null; + $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; - $optionalKeys = array_fill_keys($constantArray->getOptionalKeys(), true); $valueVarName = $stmt->valueVar->name; $keyVarName = $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ? $stmt->keyVar->name : null; - $chainScope = $originalScope; - $entryScopes = []; - $breakScopes = []; - foreach ($keyTypes as $i => $keyType) { - $valueType = $valueTypes[$i]; - $isOptional = isset($optionalKeys[$i]); - - $nativeKeyType = $nativeConstantArray !== null && isset($nativeConstantArray->getKeyTypes()[$i]) - ? $nativeConstantArray->getKeyTypes()[$i] - : $keyType; - $nativeValueType = $nativeConstantArray !== null && isset($nativeConstantArray->getValueTypes()[$i]) - ? $nativeConstantArray->getValueTypes()[$i] - : $valueType; - - $iterScope = $chainScope->assignVariable( - $valueVarName, - $valueType, - $nativeValueType, - TrinaryLogic::createYes(), - ); - $iterScope = $iterScope->assignExpression( - new OriginalForeachValueExpr($valueVarName), - $valueType, - $nativeValueType, - ); - if ($keyVarName !== null) { - $iterScope = $iterScope->assignVariable( - $keyVarName, - $keyType, - $nativeKeyType, + $allBodyScopes = []; + $allChainScopes = []; + $allBreakScopes = []; + + foreach ($constantArrays as $arrayIndex => $constantArray) { + $keyTypes = $constantArray->getKeyTypes(); + $valueTypes = $constantArray->getValueTypes(); + if (count($keyTypes) === 0) { + continue; + } + + $nativeConstantArray = $matchedNativeArrays !== null ? $matchedNativeArrays[$arrayIndex] : null; + $optionalKeys = array_fill_keys($constantArray->getOptionalKeys(), true); + + $chainScope = $originalScope; + $entryScopes = []; + + foreach ($keyTypes as $i => $keyType) { + $valueType = $valueTypes[$i]; + $isOptional = isset($optionalKeys[$i]); + + $nativeKeyType = $nativeConstantArray !== null && isset($nativeConstantArray->getKeyTypes()[$i]) + ? $nativeConstantArray->getKeyTypes()[$i] + : $keyType; + $nativeValueType = $nativeConstantArray !== null && isset($nativeConstantArray->getValueTypes()[$i]) + ? $nativeConstantArray->getValueTypes()[$i] + : $valueType; + + $iterScope = $chainScope->assignVariable( + $valueVarName, + $valueType, + $nativeValueType, TrinaryLogic::createYes(), ); $iterScope = $iterScope->assignExpression( - new OriginalForeachKeyExpr($keyVarName), - $keyType, - $nativeKeyType, - ); - $iterScope = $iterScope->assignExpression( - new ArrayDimFetch($stmt->expr, $stmt->keyVar), + new OriginalForeachValueExpr($valueVarName), $valueType, $nativeValueType, ); - } + if ($keyVarName !== null) { + $iterScope = $iterScope->assignVariable( + $keyVarName, + $keyType, + $nativeKeyType, + TrinaryLogic::createYes(), + ); + $iterScope = $iterScope->assignExpression( + new OriginalForeachKeyExpr($keyVarName), + $keyType, + $nativeKeyType, + ); + $iterScope = $iterScope->assignExpression( + new ArrayDimFetch($stmt->expr, $stmt->keyVar), + $valueType, + $nativeValueType, + ); + } - $entryScopes[] = $iterScope; + $entryScopes[] = $iterScope; - $iterStorage = $originalStorage->duplicate(); - $bodyResult = $this->processStmtNodesInternal( - $stmt, - $stmt->stmts, - $iterScope, - $iterStorage, - new NoopNodeCallback(), - $context->enterDeep(), - )->filterOutLoopExitPoints(); + $iterStorage = $originalStorage->duplicate(); + $bodyResult = $this->processStmtNodesInternal( + $stmt, + $stmt->stmts, + $iterScope, + $iterStorage, + new NoopNodeCallback(), + $context->enterDeep(), + )->filterOutLoopExitPoints(); - $iterEndScope = $bodyResult->getScope(); - foreach ($bodyResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $iterEndScope = $iterEndScope->mergeWith($continueExitPoint->getScope()); - } - foreach ($bodyResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $breakScopes[] = $breakExitPoint->getScope(); + $iterEndScope = $bodyResult->getScope(); + foreach ($bodyResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $iterEndScope = $iterEndScope->mergeWith($continueExitPoint->getScope()); + } + foreach ($bodyResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $allBreakScopes[] = $breakExitPoint->getScope(); + } + + if ($isOptional) { + $chainScope = $iterEndScope->mergeWith($chainScope); + } else { + $chainScope = $iterEndScope; + } } - if ($isOptional) { - $chainScope = $iterEndScope->mergeWith($chainScope); - } else { - $chainScope = $iterEndScope; + $arrayBodyScope = $entryScopes[0]; + for ($i = 1, $c = count($entryScopes); $i < $c; $i++) { + $arrayBodyScope = $arrayBodyScope->mergeWith($entryScopes[$i]); } + if (count($entryScopes) === 1) { + $arrayBodyScope = $arrayBodyScope->mergeWith($chainScope); + } + + $allBodyScopes[] = $arrayBodyScope; + $allChainScopes[] = $chainScope; } - $bodyScope = $entryScopes[0]; - for ($i = 1, $c = count($entryScopes); $i < $c; $i++) { - $bodyScope = $bodyScope->mergeWith($entryScopes[$i]); + if ($allBodyScopes === []) { + return null; + } + + $bodyScope = $allBodyScopes[0]; + for ($i = 1, $c = count($allBodyScopes); $i < $c; $i++) { + $bodyScope = $bodyScope->mergeWith($allBodyScopes[$i]); } - if (count($entryScopes) === 1) { - // For a single-iteration unrolling, the merged entry scope does - // not include any post-body state. Merge the chain end scope in - // so that rules analysing the body see that prior iterations - // (which in this case means: this same iteration, from a rule - // author's perspective) could have modified variables. - $bodyScope = $bodyScope->mergeWith($chainScope); + + $endScope = $allChainScopes[0]; + for ($i = 1, $c = count($allChainScopes); $i < $c; $i++) { + $endScope = $endScope->mergeWith($allChainScopes[$i]); } - foreach ($breakScopes as $breakScope) { - $chainScope = $chainScope->mergeWith($breakScope); + foreach ($allBreakScopes as $breakScope) { + $endScope = $endScope->mergeWith($breakScope); } - return ['bodyScope' => $bodyScope, 'endScope' => $chainScope]; + return ['bodyScope' => $bodyScope, 'endScope' => $endScope]; } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-7978.php b/tests/PHPStan/Analyser/nsrt/bug-7978.php new file mode 100644 index 00000000000..5a47433c4db --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7978.php @@ -0,0 +1,57 @@ + ['username', 'password'], + 'headers' => ['app_id', 'app_key'], + ]; + + public function doSomething(): void + { + foreach (self::FIELD_SETS as $type => $fields) { + $credentials = []; + foreach ($fields as $field) { + $credentials[$field] = 'fake'; + } + assertType("array{app_id: 'fake', app_key: 'fake'}|array{username: 'fake', password: 'fake'}", $credentials); + } + } + + /** @param list{'username', 'password'}|list{'app_id', 'app_key'} $fields */ + public function directUnionForeach(array $fields): void + { + $credentials = []; + foreach ($fields as $field) { + $credentials[$field] = 'fake'; + } + assertType("array{app_id: 'fake', app_key: 'fake'}|array{username: 'fake', password: 'fake'}", $credentials); + } + + /** @param list{'a', 'b', 'c'}|list{'x'} $fields */ + public function differentLengthArrays(array $fields): void + { + $result = []; + foreach ($fields as $field) { + $result[$field] = 1; + } + assertType("array{a: 1, b: 1, c: 1}|array{x: 1}", $result); + } +}