From f555f5049b1623768111ea45693d82576f538bae Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:33:37 +0000 Subject: [PATCH 1/9] Fix null coalesce type inference for correlated int range variables - Fixed getCoalesceType() in MutatingScope to avoid over-narrowing the right side of ?? when conditional expressions from scope merging make correlated variables all null together - The fix uses the narrowed scope only when it successfully removes null from the right type (useful for `if ($a || $b) { $a ?? $b }` patterns), and falls back to the unfiltered type otherwise - New regression test in tests/PHPStan/Analyser/nsrt/bug-14213.php Closes https://github.com/phpstan/phpstan/issues/14213 --- src/Analyser/MutatingScope.php | 8 ++++++- tests/PHPStan/Analyser/nsrt/bug-14213.php | 27 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14213.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a26a86a066..f9468484dc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -6536,7 +6536,13 @@ private function getCoalesceType(BinaryOp\Coalesce $node): Type return TypeCombinator::removeNull($this->filterByTruthyValue($issetLeftExpr)->getType($node->left)); } - $rightType = $this->filterByFalseyValue($issetLeftExpr)->getType($node->right); + $unfilteredRightType = $this->getType($node->right); + if (!$unfilteredRightType->isNull()->no()) { + $narrowedRightType = $this->filterByFalseyValue($issetLeftExpr)->getType($node->right); + $rightType = $narrowedRightType->isNull()->no() ? $narrowedRightType : $unfilteredRightType; + } else { + $rightType = $unfilteredRightType; + } if ($result === null) { return TypeCombinator::union( diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php new file mode 100644 index 0000000000..d7e22ce5b4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -0,0 +1,27 @@ +|null', $x); + } +} From 6c3a01e49ca7a1ccea4ba9694442afe2bb52f267 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 08:47:05 +0100 Subject: [PATCH 2/9] test mix of undefined and int-ranges --- tests/PHPStan/Analyser/nsrt/bug-14213.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php index d7e22ce5b4..62e511bbb8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14213.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -24,4 +24,25 @@ public static function coalesce_int_range(): void assertType('int<0, 5>|null', $x); } + + public static function coalesce_int_range_after_maybe_defined(): void + { + $x0 = $x1 = $x2 = null; + + if (rand(0, 1)) { + $maybeDefined = 10; + $x0 = rand(0, 1); + $x1 = rand(2, 3); + $x2 = rand(4, 5); + } + + $x = ( + $maybeDefined ?? + $x0 ?? + $x1 ?? + $x2 + ); + + assertType('int<0, 5>|10|null', $x); + } } From 955e80157f180073eeb9c9b2662105e3e74a9828 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 08:50:24 +0100 Subject: [PATCH 3/9] Update bug-14213.php --- tests/PHPStan/Analyser/nsrt/bug-14213.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php index 62e511bbb8..a9602fb63b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14213.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -43,6 +43,6 @@ public static function coalesce_int_range_after_maybe_defined(): void $x2 ); - assertType('int<0, 5>|10|null', $x); + assertType('10|int<0, 5>|null', $x); } } From f3abf41228f21fd3705fc59289df0e49d4edf0a8 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 09:03:35 +0100 Subject: [PATCH 4/9] test non-nullable last element --- tests/PHPStan/Analyser/nsrt/bug-14213.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php index a9602fb63b..39ba548ca8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14213.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -45,4 +45,24 @@ public static function coalesce_int_range_after_maybe_defined(): void assertType('10|int<0, 5>|null', $x); } + + public static function coalesce_int_range_with_last_nullable(): void + { + $x0 = $x1 = null; + $x2 = 20; + + if (rand(0, 1)) { + $x0 = rand(0, 1); + $x1 = rand(2, 3); + $x2 = rand(4, 5); + } + + $x = ( + $x0 ?? + $x1 ?? + $x2 // cannot be null + ); + + assertType('20|int<0, 5>', $x); + } } From 42c5047f9edac62216f8f5689da7d2069f0a4286 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 09:04:11 +0100 Subject: [PATCH 5/9] Update bug-14213.php --- tests/PHPStan/Analyser/nsrt/bug-14213.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php index 39ba548ca8..e989870109 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14213.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -46,7 +46,7 @@ public static function coalesce_int_range_after_maybe_defined(): void assertType('10|int<0, 5>|null', $x); } - public static function coalesce_int_range_with_last_nullable(): void + public static function coalesce_int_range_with_last_non_nullable(): void { $x0 = $x1 = null; $x2 = 20; From c9db820629e579a448b3168db30b32144b44e7ff Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 10:56:43 +0100 Subject: [PATCH 6/9] fix --- tests/PHPStan/Analyser/nsrt/bug-14213.php | 14 ++++++++++++++ .../Rules/Variables/NullCoalesceRuleTest.php | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php index e989870109..a766fba4dc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14213.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -12,7 +12,11 @@ public static function coalesce_int_range(): void if (rand(0, 1)) { $x0 = rand(0, 1); + } + if (rand(0, 1)) { $x1 = rand(2, 3); + } + if (rand(0, 1)) { $x2 = rand(4, 5); } @@ -31,8 +35,14 @@ public static function coalesce_int_range_after_maybe_defined(): void if (rand(0, 1)) { $maybeDefined = 10; + } + if (rand(0, 1)) { $x0 = rand(0, 1); + } + if (rand(0, 1)) { $x1 = rand(2, 3); + } + if (rand(0, 1)) { $x2 = rand(4, 5); } @@ -53,7 +63,11 @@ public static function coalesce_int_range_with_last_non_nullable(): void if (rand(0, 1)) { $x0 = rand(0, 1); + } + if (rand(0, 1)) { $x1 = rand(2, 3); + } + if (rand(0, 1)) { $x2 = rand(4, 5); } diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index de2e966abb..b3a737e347 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -352,4 +352,9 @@ public function testPr4372(): void $this->analyse([__DIR__ . '/data/pr-4372-null-coalesce.php'], []); } + public function testBug14213(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14213.php'], []); + } + } From afe06dc2444a3ab3f07939d9cb36e3b6c0d6158b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 10:56:57 +0100 Subject: [PATCH 7/9] Discard changes to src/Analyser/MutatingScope.php --- src/Analyser/MutatingScope.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f9468484dc..a26a86a066 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -6536,13 +6536,7 @@ private function getCoalesceType(BinaryOp\Coalesce $node): Type return TypeCombinator::removeNull($this->filterByTruthyValue($issetLeftExpr)->getType($node->left)); } - $unfilteredRightType = $this->getType($node->right); - if (!$unfilteredRightType->isNull()->no()) { - $narrowedRightType = $this->filterByFalseyValue($issetLeftExpr)->getType($node->right); - $rightType = $narrowedRightType->isNull()->no() ? $narrowedRightType : $unfilteredRightType; - } else { - $rightType = $unfilteredRightType; - } + $rightType = $this->filterByFalseyValue($issetLeftExpr)->getType($node->right); if ($result === null) { return TypeCombinator::union( From d3ee15afb9717c928d9bbd2df4420b3632dcaaa0 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 11:11:49 +0100 Subject: [PATCH 8/9] expect errors --- tests/PHPStan/Analyser/nsrt/bug-14213.php | 22 ++++++++++++++++++- .../Rules/Variables/NullCoalesceRuleTest.php | 7 +++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14213.php b/tests/PHPStan/Analyser/nsrt/bug-14213.php index a766fba4dc..50dd715229 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14213.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14213.php @@ -6,7 +6,27 @@ class HelloWorld { - public static function coalesce_int_range(): void + public static function coalesce_nonsensical(): void + { + $x0 = $x1 = $x2 = null; + + if (rand(0, 1)) { + $x0 = rand(0, 1); + $x1 = rand(2, 3); + $x2 = rand(4, 5); + } + + // either all 3 variables are null, or all have a int-range value + $x = ( + $x0 ?? + $x1 ?? + $x2 + ); + + assertType('int<0, 1>|null', $x); + } + + public static function coalesce_int_ranges(): void { $x0 = $x1 = $x2 = null; diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index b3a737e347..5325c4ee5e 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -354,7 +354,12 @@ public function testPr4372(): void public function testBug14213(): void { - $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14213.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14213.php'], [ + [ + 'Variable $x1 on left side of ?? always exists and is always null.', + 21, + ], + ]); } } From b4249b7ba9ee26cfbc772abeeb2fa233a74f81b4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Mar 2026 11:17:00 +0100 Subject: [PATCH 9/9] Update NullCoalesceRuleTest.php --- tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 5325c4ee5e..56b902212a 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -357,7 +357,7 @@ public function testBug14213(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14213.php'], [ [ 'Variable $x1 on left side of ?? always exists and is always null.', - 21, + 22, ], ]); }