From b320047364b7d3272b27ff835d84c68122ca0d15 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:22:36 +0000 Subject: [PATCH 1/4] Re-add complement of a dropped self-condition to boolean-decomposition holder types - In `ConditionalExpressionHolderHelper::processBooleanConditionalTypes()`, a condition narrowing the holder's *target* expression itself is dropped (holders fire by matching other expressions). That dropped condition restricted the target to a subset of its values, so the resulting holder over-narrowed the target when only the remaining conditions later hold. - Now, when such a self-condition is dropped, the values it excluded (the complement of its type within the target's scope type) are unioned back into the holder type, so the holder stays sound. - Fixes `!(isset($a['foo']) && !is_array($a['foo']))` followed by `array_key_exists('foo', $a)` inferring `$a['foo']` as `array` instead of the correct `array|null` (the `isset` non-null self-condition was silently dropped). - The same helper backs both `BooleanAndHandler` and `BooleanOrHandler`, and the `is_*` arm is not special-cased, so the mirror `is_int`/`||` cases are fixed by the same change. --- .../ConditionalExpressionHolderHelper.php | 19 ++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14874.php | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14874.php diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index e8c1008c183..e11d3e88c06 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -201,10 +201,12 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con } $conditions = $conditionExpressionTypes; + $droppedSelfCondition = null; foreach (array_keys($conditions) as $conditionExprString) { if ($conditionExprString !== $exprString) { continue; } + $droppedSelfCondition = $conditions[$conditionExprString]; unset($conditions[$conditionExprString]); } @@ -218,6 +220,23 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); + // A condition on the target expression itself cannot be tracked + // (the holder fires by matching *other* expressions) so it is + // dropped above. But that condition restricted the target to a + // subset of its values; without it the holder must also allow the + // values the condition excluded, otherwise it over-narrows the + // target when only the remaining conditions hold. Example: + // `!(isset($a['foo']) && !is_array($a['foo']))` keyed on + // `$a` having offset 'foo' must yield `array|null`, not `array`, + // because `$a['foo']` may be null when the dropped `isset` (i.e. + // non-null) condition does not hold. + if ($droppedSelfCondition !== null) { + $complement = TypeCombinator::remove($scope->getType($expr), $droppedSelfCondition->getType()); + if (!$complement instanceof NeverType) { + $holderType = TypeCombinator::union($holderType, $complement); + } + } + // These boolean-decomposition holders only refine an expression's // type in a future scope; they must never collapse it to never and // thereby mark the whole scope unreachable. A never result is an diff --git a/tests/PHPStan/Analyser/nsrt/bug-14874.php b/tests/PHPStan/Analyser/nsrt/bug-14874.php new file mode 100644 index 00000000000..28506ef3e38 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14874.php @@ -0,0 +1,29 @@ + $a + */ +function test(array $a): bool { + if (isset($a['foo']) && !is_array($a['foo'])) + return false; + if (array_key_exists('foo', $a)) { + assertType('array|null', $a['foo']); + return $a['foo'] === null; // possible + } + return false; +} + +/** + * @param array $a + */ +function testIs(array $a): void { + if (isset($a['foo']) && !is_int($a['foo'])) + return; + if (array_key_exists('foo', $a)) { + assertType('int|null', $a['foo']); + } +} From 7cd1f6e9f0fa6db00ccde34ef43103c639a455ab Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 08:27:41 +0000 Subject: [PATCH 2/4] Shorten dropped-self-condition comment Co-Authored-By: Claude Opus 4.8 --- .../Helper/ConditionalExpressionHolderHelper.php | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index e11d3e88c06..8aaae42d870 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -220,16 +220,9 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); - // A condition on the target expression itself cannot be tracked - // (the holder fires by matching *other* expressions) so it is - // dropped above. But that condition restricted the target to a - // subset of its values; without it the holder must also allow the - // values the condition excluded, otherwise it over-narrows the - // target when only the remaining conditions hold. Example: - // `!(isset($a['foo']) && !is_array($a['foo']))` keyed on - // `$a` having offset 'foo' must yield `array|null`, not `array`, - // because `$a['foo']` may be null when the dropped `isset` (i.e. - // non-null) condition does not hold. + // The dropped self-condition narrowed the target; without it the + // holder must allow the values it excluded, or it over-narrows when + // only the remaining conditions hold. So union back the complement. if ($droppedSelfCondition !== null) { $complement = TypeCombinator::remove($scope->getType($expr), $droppedSelfCondition->getType()); if (!$complement instanceof NeverType) { From b6524258c4187dd36de4be14d15b2870852640b8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 08:44:52 +0000 Subject: [PATCH 3/4] Iterate conditions directly instead of array_keys Co-Authored-By: Claude Opus 4.8 --- .../ExprHandler/Helper/ConditionalExpressionHolderHelper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 8aaae42d870..5634e6a5b4e 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -202,11 +202,11 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con $conditions = $conditionExpressionTypes; $droppedSelfCondition = null; - foreach (array_keys($conditions) as $conditionExprString) { + foreach ($conditions as $conditionExprString => $condition) { if ($conditionExprString !== $exprString) { continue; } - $droppedSelfCondition = $conditions[$conditionExprString]; + $droppedSelfCondition = $condition; unset($conditions[$conditionExprString]); } From 7fbd0cd9077e3273b3c8b510ae8f576938f29386 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 29 Jun 2026 11:02:56 +0200 Subject: [PATCH 4/4] cs --- .../ExprHandler/Helper/ConditionalExpressionHolderHelper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 5634e6a5b4e..a59f29cd6d6 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -17,7 +17,6 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NeverType; use PHPStan\Type\TypeCombinator; -use function array_keys; use function count; use function is_string;