From 39ed27d75b1f5dc51b9f833ca50f61d7fcd5b68c Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 3 May 2026 10:38:08 +0000 Subject: [PATCH 1/2] Narrow parent constant array type when type-checking an array offset When is_string($arr['key']) narrows the offset type, also propagate a sureNotType on the parent array variable to remove constant array members whose offset value is fully covered by the narrowing. This prevents false positives when BooleanAnd's intersectWith drops entries for different expression keys. Also extend the coalesce simplification in createForExpr's false context to handle non-null type checks (e.g. is_string($x ?? null) in the falsey branch can be simplified to just narrowing $x). Closes https://github.com/phpstan/phpstan/issues/14566 --- src/Analyser/TypeSpecifier.php | 38 ++++++++++++++++++- .../Arrays/OffsetAccessAssignmentRuleTest.php | 12 ++++++ tests/PHPStan/Rules/Arrays/data/bug-14566.php | 23 +++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14566.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9f7a86eb4a..c78150ac26 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2470,7 +2470,7 @@ private function createForExpr( ) { if ( ($context->true() && $type->isSuperTypeOf($scope->getType($expr->right))->no()) - || ($context->false() && $type->isSuperTypeOf($scope->getType($expr->right))->yes()) + || ($context->false() && ($type->isSuperTypeOf($scope->getType($expr->right))->yes() || $type->isNull()->no())) ) { $expr = $expr->left; } @@ -2586,6 +2586,42 @@ private function createForExpr( } } + if ( + $expr instanceof ArrayDimFetch + && $expr->dim !== null + && !$context->null() + ) { + $dimType = $scope->getType($expr->dim); + if ($dimType instanceof ConstantIntegerType || $dimType->getConstantStrings() !== []) { + $varType = $scope->getType($expr->var); + $constantArrays = $varType->getConstantArrays(); + if ($constantArrays !== []) { + $typesToRemove = []; + foreach ($constantArrays as $constantArray) { + if (!$constantArray->hasOffsetValueType($dimType)->yes()) { + continue; + } + $offsetValueType = $constantArray->getOffsetValueType($dimType); + if ($context->false()) { + if ($type->isSuperTypeOf($offsetValueType)->yes()) { + $typesToRemove[] = $constantArray; + } + } elseif ($context->true()) { + if ($type->isSuperTypeOf($offsetValueType)->no()) { + $typesToRemove[] = $constantArray; + } + } + } + + if ($typesToRemove !== [] && count($typesToRemove) < count($constantArrays)) { + $typeToRemove = TypeCombinator::union(...$typesToRemove); + $varExprString = $this->exprPrinter->printExpr($expr->var); + $sureNotTypes[$varExprString] = [$expr->var, $typeToRemove]; + } + } + } + } + $types = new SpecifiedTypes($sureTypes, $sureNotTypes); if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type)->unionWith($types); diff --git a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php index 4ee0a096b6..430eecb245 100644 --- a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php @@ -222,4 +222,16 @@ public function testAppendToArrayWithPhpIntMaxKey(): void ]); } + public function testBug9004(): void + { + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-9004.php'], []); + } + + public function testBug14566(): void + { + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14566.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14566.php b/tests/PHPStan/Rules/Arrays/data/bug-14566.php new file mode 100644 index 0000000000..26f0dcef03 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14566.php @@ -0,0 +1,23 @@ + Date: Sun, 3 May 2026 10:54:19 +0000 Subject: [PATCH 2/2] Add test cases for already-working equivalent patterns Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Arrays/data/bug-14566.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14566.php b/tests/PHPStan/Rules/Arrays/data/bug-14566.php index 26f0dcef03..66bcb8c13e 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-14566.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-14566.php @@ -21,3 +21,26 @@ function foo2(array $test): void { } $test['hi'][] = 42; } + +/** + * @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test + */ +function foo3(array $test): void { + if (!isset($test['hi'])) { + return; + } + if (\is_string($test['hi'])) { + return; + } + $test['hi'][] = 42; +} + +/** + * @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test + */ +function foo4(array $test): void { + if (!\is_array($test['hi'] ?? null)) { + return; + } + $test['hi'][] = 42; +}