From 5e30e5df4a16198f101bc29408368b2c0ca6ea60 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:10:58 +0000 Subject: [PATCH] Fix phpstan/phpstan#9691: Preserve per-key array types during loop generalization when values mix arrays and scalars When an array has different keys holding structurally different value types (e.g., one key holds an int, another holds an array), loop type generalization would collapse all per-key type information into a general array, causing false "Cannot access offset" errors when accessing keys that are known to hold array values. Add a new branch in MutatingScope::generalizeType() that detects when the wider array (B) has keys that are a superset of the narrower (A) and the value types span both array and non-array types. In this case, perform per-key value type merging to preserve structural type info. --- src/Analyser/MutatingScope.php | 56 ++++++++++++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-9691.php | 23 ++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9691.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 122340cc27b..1fb4fcd6c1a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4086,12 +4086,14 @@ private function generalizeType(Type $a, Type $b, int $depth): Type } else { $constantArraysA = TypeCombinator::union(...$constantArrays['a']); $constantArraysB = TypeCombinator::union(...$constantArrays['b']); + $aKeyType = $constantArraysA->getIterableKeyType(); + $bKeyType = $constantArraysB->getIterableKeyType(); if ( - $constantArraysA->getIterableKeyType()->equals($constantArraysB->getIterableKeyType()) + $aKeyType->equals($bKeyType) && $constantArraysA->getArraySize()->getGreaterOrEqualType($this->phpVersion)->isSuperTypeOf($constantArraysB->getArraySize())->yes() ) { $resultArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - foreach (TypeUtils::flattenTypes($constantArraysA->getIterableKeyType()) as $keyType) { + foreach (TypeUtils::flattenTypes($aKeyType) as $keyType) { $resultArrayBuilder->setOffsetValueType( $keyType, $this->generalizeType( @@ -4103,10 +4105,40 @@ private function generalizeType(Type $a, Type $b, int $depth): Type ); } + $resultTypes[] = $resultArrayBuilder->getArray(); + } elseif ( + $bKeyType->isSuperTypeOf($aKeyType)->yes() + && !$aKeyType->equals($bKeyType) + && $this->hasStructurallyMixedValueTypes($constantArraysB) + ) { + $resultArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + foreach (TypeUtils::flattenTypes($bKeyType) as $keyType) { + $hasInA = $constantArraysA->hasOffsetValueType($keyType); + $hasInB = $constantArraysB->hasOffsetValueType($keyType); + + if ($hasInA->no()) { + $valueType = $constantArraysB->getOffsetValueType($keyType); + } elseif ($hasInB->no()) { + $valueType = $constantArraysA->getOffsetValueType($keyType); + } else { + $valueType = $this->generalizeType( + $constantArraysA->getOffsetValueType($keyType), + $constantArraysB->getOffsetValueType($keyType), + $depth + 1, + ); + } + + $resultArrayBuilder->setOffsetValueType( + $keyType, + $valueType, + !$hasInA->and($hasInB)->negate()->no(), + ); + } + $resultTypes[] = $resultArrayBuilder->getArray(); } else { $resultType = new ArrayType( - TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)), + TypeCombinator::union($this->generalizeType($aKeyType, $bKeyType, $depth + 1)), TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)), ); $accessories = []; @@ -4349,6 +4381,24 @@ private static function getArrayDepth(Type $type): int return $depth; } + private function hasStructurallyMixedValueTypes(Type $arrayType): bool + { + $hasArrayValuePart = false; + $hasNonArrayValuePart = false; + foreach (TypeUtils::flattenTypes($arrayType->getIterableValueType()) as $innerType) { + if ($innerType->isArray()->yes()) { + $hasArrayValuePart = true; + } + if (!$innerType->isArray()->no()) { + continue; + } + + $hasNonArrayValuePart = true; + } + + return $hasArrayValuePart && $hasNonArrayValuePart; + } + public function equals(self $otherScope): bool { if (!$this->context->equals($otherScope->context)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-9691.php b/tests/PHPStan/Analyser/nsrt/bug-9691.php new file mode 100644 index 00000000000..13ce0d833ff --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9691.php @@ -0,0 +1,23 @@ +