From 869b3e4a5d8d66ee44be43b930d5c012e57ae28c Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:19:50 +0000 Subject: [PATCH 1/4] Fix phpstan/phpstan#14394: NAN ==/=== anything is always evaluated as false - Added containsNan() helper in InitializerExprTypeResolver to recursively check if a type (including constant arrays) contains NAN - resolveIdenticalType() and resolveEqualType() now return always-false when either operand contains NAN - Updated bug-11054 test expectations: [NAN] === mixed is now correctly reported as always false (matching PHP runtime behavior) - New regression test in tests/PHPStan/Rules/Comparison/data/bug-14394.php --- .../InitializerExprTypeResolver.php | 26 +++++++++++++++++++ .../ConstantLooseComparisonRuleTest.php | 15 +++++++++++ ...rictComparisonOfDifferentTypesRuleTest.php | 26 +++++++++++++++++++ .../Rules/Comparison/data/bug-14394.php | 13 ++++++++++ 4 files changed, 80 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14394.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index b4578930acf..e804db996c6 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -114,6 +114,7 @@ use function is_finite; use function is_float; use function is_int; +use function is_nan; use function is_numeric; use function is_string; use function max; @@ -1944,6 +1945,10 @@ public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResul return new TypeResult(new ConstantBooleanType(false), []); } + if ($this->containsNan($leftType) || $this->containsNan($rightType)) { + return new TypeResult(new ConstantBooleanType(false), []); + } + if ($leftType instanceof ConstantScalarType && $rightType instanceof ConstantScalarType) { return new TypeResult(new ConstantBooleanType($leftType->getValue() === $rightType->getValue()), []); } @@ -1972,6 +1977,10 @@ public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResul */ public function resolveEqualType(Type $leftType, Type $rightType): TypeResult { + if ($this->containsNan($leftType) || $this->containsNan($rightType)) { + return new TypeResult(new ConstantBooleanType(false), []); + } + if ( ($leftType->isEnum()->yes() && $rightType->isTrue()->no()) || ($rightType->isEnum()->yes() && $leftType->isTrue()->no()) @@ -2061,6 +2070,23 @@ private function resolveConstantArrayTypeComparison(ConstantArrayType $leftType, return new TypeResult($resultType->toBoolean(), []); } + private function containsNan(Type $type): bool + { + if ($type instanceof ConstantFloatType && is_nan($type->getValue())) { + return true; + } + + foreach ($type->getConstantArrays() as $constantArray) { + foreach ($constantArray->getValueTypes() as $valueType) { + if ($this->containsNan($valueType)) { + return true; + } + } + } + + return false; + } + /** * @param BinaryOp\Plus|BinaryOp\Minus|BinaryOp\Mul|BinaryOp\Div|BinaryOp\ShiftLeft|BinaryOp\ShiftRight $expr */ diff --git a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php index bb6aa370ac4..64c36ee0e9a 100644 --- a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php @@ -248,4 +248,19 @@ public function testBug13098(): void $this->analyse([__DIR__ . '/data/bug-13098.php'], []); } + public function testBug14394(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14394.php'], [ + [ + 'Loose comparison using == between float and NAN will always evaluate to false.', + 8, + ], + [ + 'Loose comparison using == between list and array{NAN} will always evaluate to false.', + 10, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index f2f43732108..78369cdaf13 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1164,11 +1164,37 @@ public function testPossiblyImpureTip(): void public function testBug11054(): void { $this->analyse([__DIR__ . '/data/bug-11054.php'], [ + [ + 'Strict comparison using === between mixed and array{NAN} will always evaluate to false.', + 24, + ], + [ + 'Strict comparison using === between mixed and array{NAN} will always evaluate to false.', + 28, + ], [ 'Strict comparison using === between mixed and array{INF} will always evaluate to false.', 47, 'Type array{INF} has already been eliminated from mixed.', ], + [ + 'Strict comparison using === between mixed and array{NAN} will always evaluate to false.', + 55, + ], + ]); + } + + public function testBug14394(): void + { + $this->analyse([__DIR__ . '/data/bug-14394.php'], [ + [ + 'Strict comparison using === between float and NAN will always evaluate to false.', + 9, + ], + [ + 'Strict comparison using === between list and array{NAN} will always evaluate to false.', + 11, + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14394.php b/tests/PHPStan/Rules/Comparison/data/bug-14394.php new file mode 100644 index 00000000000..e1850bb9b66 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14394.php @@ -0,0 +1,13 @@ + $v2 */ + public static function test(float $v1, array $v2): void { + if ($v1 == NAN) { echo "never reached\n"; } + if ($v1 === NAN) { echo "never reached\n"; } + if ($v2 == [NAN]) { echo "never reached\n"; } + if ($v2 === [NAN]) { echo "never reached\n"; } + } +} From b5638cc0ac4d22ed90d9348cd50109159d4265ba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 17:12:07 +0000 Subject: [PATCH 2/4] Fix containsNan to handle union array types and optional keys When a type is a union of constant arrays (e.g. array{NAN}|array{1}), containsNan should only return true if ALL variants contain NAN, not just any of them. Also skip optional keys since absent optional entries mean the array might not actually contain NAN. Co-Authored-By: Claude Opus 4.6 --- .../InitializerExprTypeResolver.php | 26 ++++++++++++++++--- .../Rules/Comparison/data/bug-14394.php | 6 +++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index e804db996c6..fb56551c560 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2076,12 +2076,30 @@ private function containsNan(Type $type): bool return true; } - foreach ($type->getConstantArrays() as $constantArray) { - foreach ($constantArray->getValueTypes() as $valueType) { - if ($this->containsNan($valueType)) { - return true; + $constantArrays = $type->getConstantArrays(); + if ($constantArrays !== [] && $type->isConstantArray()->yes()) { + foreach ($constantArrays as $constantArray) { + if (!$this->constantArrayContainsNan($constantArray)) { + return false; } } + + return true; + } + + return false; + } + + private function constantArrayContainsNan(ConstantArrayType $constantArray): bool + { + $optionalKeys = $constantArray->getOptionalKeys(); + foreach ($constantArray->getValueTypes() as $index => $valueType) { + if (in_array($index, $optionalKeys, true)) { + continue; + } + if ($this->containsNan($valueType)) { + return true; + } } return false; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14394.php b/tests/PHPStan/Rules/Comparison/data/bug-14394.php index e1850bb9b66..c54d1b0b127 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14394.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14394.php @@ -10,4 +10,10 @@ public static function test(float $v1, array $v2): void { if ($v2 == [NAN]) { echo "never reached\n"; } if ($v2 === [NAN]) { echo "never reached\n"; } } + + /** @param array{NAN}|array{1} $v */ + public static function testUnionArrayNotAlwaysFalse(array $v): void { + if ($v === [1]) { echo "maybe reached\n"; } + if ($v == [1]) { echo "maybe reached\n"; } + } } From f0cb81a0160d78f60fe4985749c0669a1c593183 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 18:21:09 +0000 Subject: [PATCH 3/4] Simplify containsNan: remove isConstantArray check and inline constantArrayContainsNan Address review feedback from VincentLanglet: - Remove redundant isConstantArray()->yes() check - Inline constantArrayContainsNan into containsNan to avoid a dedicated method Co-Authored-By: Claude Opus 4.6 --- .../InitializerExprTypeResolver.php | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index fb56551c560..b317aca4642 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2077,9 +2077,20 @@ private function containsNan(Type $type): bool } $constantArrays = $type->getConstantArrays(); - if ($constantArrays !== [] && $type->isConstantArray()->yes()) { + if ($constantArrays !== []) { foreach ($constantArrays as $constantArray) { - if (!$this->constantArrayContainsNan($constantArray)) { + $optionalKeys = $constantArray->getOptionalKeys(); + $hasNan = false; + foreach ($constantArray->getValueTypes() as $index => $valueType) { + if (in_array($index, $optionalKeys, true)) { + continue; + } + if ($this->containsNan($valueType)) { + $hasNan = true; + break; + } + } + if (!$hasNan) { return false; } } @@ -2090,21 +2101,6 @@ private function containsNan(Type $type): bool return false; } - private function constantArrayContainsNan(ConstantArrayType $constantArray): bool - { - $optionalKeys = $constantArray->getOptionalKeys(); - foreach ($constantArray->getValueTypes() as $index => $valueType) { - if (in_array($index, $optionalKeys, true)) { - continue; - } - if ($this->containsNan($valueType)) { - return true; - } - } - - return false; - } - /** * @param BinaryOp\Plus|BinaryOp\Minus|BinaryOp\Mul|BinaryOp\Div|BinaryOp\ShiftLeft|BinaryOp\ShiftRight $expr */ From 2302b10a22837867f8d2e71d3214f3321807ffb9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 18:38:15 +0000 Subject: [PATCH 4/4] Fix NAN same-variable comparison: $a === $a where $a is NAN should be false The same-variable short-circuit in RicherScopeGetTypeHelper and BinaryOpHandler assumed $a === $a and $a == $a are always true, but this is incorrect when $a is NAN (NAN !== NAN in PHP). Added NAN check before the short-circuit, and made containsNan public for reuse. Added test cases for $a = NAN; $a == $a; [$a] == [$a] as requested. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/BinaryOpHandler.php | 5 ++++- src/Analyser/RicherScopeGetTypeHelper.php | 5 ++++- src/Reflection/InitializerExprTypeResolver.php | 2 +- .../Rules/Comparison/ConstantLooseComparisonRuleTest.php | 8 ++++++++ .../StrictComparisonOfDifferentTypesRuleTest.php | 8 ++++++++ tests/PHPStan/Rules/Comparison/data/bug-14394.php | 8 ++++++++ 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index b9d0908f78f..31792777b00 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -109,7 +109,10 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type && is_string($expr->right->name) && $expr->left->name === $expr->right->name ) { - return new ConstantBooleanType(true); + $varType = $scope->getType($expr->left); + if (!$this->initializerExprTypeResolver->containsNan($varType)) { + return new ConstantBooleanType(true); + } } $leftType = $scope->getType($expr->left); diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 132c1875809..7ca0e00b6ba 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -36,7 +36,10 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult && is_string($expr->right->name) && $expr->left->name === $expr->right->name ) { - return new TypeResult(new ConstantBooleanType(true), []); + $varType = $scope->getType($expr->left); + if (!$this->initializerExprTypeResolver->containsNan($varType)) { + return new TypeResult(new ConstantBooleanType(true), []); + } } $leftType = $scope->getType($expr->left); diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index b317aca4642..31d47604a7c 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2070,7 +2070,7 @@ private function resolveConstantArrayTypeComparison(ConstantArrayType $leftType, return new TypeResult($resultType->toBoolean(), []); } - private function containsNan(Type $type): bool + public function containsNan(Type $type): bool { if ($type instanceof ConstantFloatType && is_nan($type->getValue())) { return true; diff --git a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php index 64c36ee0e9a..0ee1741cc95 100644 --- a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php @@ -260,6 +260,14 @@ public function testBug14394(): void 'Loose comparison using == between list and array{NAN} will always evaluate to false.', 10, ], + [ + 'Loose comparison using == between NAN and NAN will always evaluate to false.', + 16, + ], + [ + 'Loose comparison using == between array{NAN} and array{NAN} will always evaluate to false.', + 18, + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 78369cdaf13..cd2fd5075c3 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1195,6 +1195,14 @@ public function testBug14394(): void 'Strict comparison using === between list and array{NAN} will always evaluate to false.', 11, ], + [ + 'Strict comparison using === between NAN and NAN will always evaluate to false.', + 17, + ], + [ + 'Strict comparison using === between array{NAN} and array{NAN} will always evaluate to false.', + 19, + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14394.php b/tests/PHPStan/Rules/Comparison/data/bug-14394.php index c54d1b0b127..4f4fad13df9 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14394.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14394.php @@ -11,6 +11,14 @@ public static function test(float $v1, array $v2): void { if ($v2 === [NAN]) { echo "never reached\n"; } } + public static function testSameVariable(): void { + $a = NAN; + if ($a == $a) { echo "never reached\n"; } + if ($a === $a) { echo "never reached\n"; } + if ([$a] == [$a]) { echo "never reached\n"; } + if ([$a] === [$a]) { echo "never reached\n"; } + } + /** @param array{NAN}|array{1} $v */ public static function testUnionArrayNotAlwaysFalse(array $v): void { if ($v === [1]) { echo "maybe reached\n"; }