From bdf955de2cf1f96e9af3364ff6501831bc0651f3 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:08:45 +0000 Subject: [PATCH] Fix ErrorType leaking from array_key_exists with union key types in loops When array_key_exists() was called with a union constant key type (e.g., 'c1'|'c2') on an empty array inside a loop, the NonEmptyArrayType narrowing was skipped due to a count(getConstantScalarTypes()) <= 1 guard. This caused the isIterableAtLeastOnce()->no() early return to produce empty SpecifiedTypes, leaving the array typed as array{} even in the true branch. Subsequent access to $arr[$key] on the empty array type produced ErrorType, which propagated through loop iterations and eventually caused false "Unable to resolve template type" errors when the array was passed to generic functions like array_values(). The fix removes the <= 1 guard so NonEmptyArrayType narrowing always applies when array_key_exists returns true, which is semantically correct regardless of key type. Closes https://github.com/phpstan/phpstan/issues/14489 --- ...yExistsFunctionTypeSpecifyingExtension.php | 20 ++++---- tests/PHPStan/Analyser/nsrt/bug-14489.php | 46 +++++++++++++++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 2 +- 3 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14489.php diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index ffbdc4069fb..c48fee653fd 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -77,17 +77,15 @@ public function specifyTypes( if ($context->true()) { $specifiedTypes = new SpecifiedTypes(); - if (count($keyType->getConstantScalarTypes()) <= 1) { - $nonEmptyType = $arrayType->isArray()->yes() - ? new NonEmptyArrayType() - : TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType()); - $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( - $array, - $nonEmptyType, - $context, - $scope, - )); - } + $nonEmptyType = $arrayType->isArray()->yes() + ? new NonEmptyArrayType() + : TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType()); + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( + $array, + $nonEmptyType, + $context, + $scope, + )); if ($arrayType->isIterableAtLeastOnce()->no()) { return $specifiedTypes; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14489.php b/tests/PHPStan/Analyser/nsrt/bug-14489.php new file mode 100644 index 00000000000..58f8d17b715 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14489.php @@ -0,0 +1,46 @@ + [1], 'c2' => [4]]]; + + $cData = []; + foreach ($data as $cMap) { + foreach ($cMap as $c => $ids) { + if (array_key_exists($c, $cData)) { + $cData[$c] = array_unique(array_merge($cData[$c], $ids)); + } else { + $cData[$c] = $ids; + } + } + } + + $values = array_values($cData); + assertType('non-empty-list>', $values); +}; + +function () { + /** @var 'c1'|'c2' $c */ + $c = 'c1'; + /** @var array{1}|array{4} $ids */ + $ids = [1]; + + $cData = []; + while (rand(0, 1)) { + if (array_key_exists($c, $cData)) { + assertType('non-empty-array<\'c1\'|\'c2\', array{1}|array{4}>', $cData); + assertType('array{1}|array{4}', $cData[$c]); + $cData[$c] = $cData[$c]; + } else { + $cData[$c] = $ids; + } + } + assertType('array<\'c1\'|\'c2\', array{1}|array{4}>', $cData); +}; diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index a3a31d39480..7cd71f4123e 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -419,7 +419,7 @@ public function testBug7000b(): void { $this->analyse([__DIR__ . '/data/bug-7000b.php'], [ [ - "Offset 'require'|'require-dev' might not exist on array{require?: array, require-dev?: array}.", + "Offset 'require'|'require-dev' might not exist on non-empty-array{require?: array, require-dev?: array}.", 16, ], ]);