From d8a8abe627f0b4797655923222e3845c55ce5c7d Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:41:02 +0000 Subject: [PATCH 1/2] Resolve array offset access on `never` to `never` and treat `never` operands of `===`/`!==` as undecided - ArrayDimFetchHandler::resolveType() now short-circuits when the offset-accessible type is NeverType. Because never is a subtype of everything (including ArrayAccess), the fetch was otherwise resolved through offsetGet() and produced an *ERROR* type instead of never. - InitializerExprTypeResolver::resolveIdenticalType() returns a non-constant BooleanType (instead of ConstantBooleanType(false)) when either operand is never. A never-typed operand has no value to compare, so the comparison is undecided. This mirrors how never already behaves as a boolean condition (if/&&/||) and stops StrictComparison / ImpossibleCheck rules from piling always-true/false errors onto already-unreachable code. - Together these let an impossible assertion such as `assert($array[1] === null)` collapse the array to never without emitting a cascade of `*NEVER* === ...` comparison errors on the following statements. - Updated the last-match-arm rule test (the `*NEVER* === 'ccc'` reports are now suppressed) and bug-9307 (`array<*ERROR*>` is now correctly inferred as `array`). - Probed siblings: `==`/`!=` already resolve to a non-constant boolean via NeverType::looseCompare(); `<=>`/`<`/`>` yield never without an always-true/false report, so no change was needed there. --- .../ExprHandler/ArrayDimFetchHandler.php | 7 ++++ .../InitializerExprTypeResolver.php | 5 ++- tests/PHPStan/Analyser/data/bug-9307.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14281.php | 37 +++++++++++++++++++ ...rictComparisonOfDifferentTypesRuleTest.php | 26 +++++++++---- .../Rules/Comparison/data/bug-14281.php | 30 +++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14281.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14281.php diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 1c389f9fb9a..a6c2ba01744 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -47,6 +47,13 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } $offsetAccessibleType = $scope->getType($expr->var); + if ($offsetAccessibleType instanceof NeverType) { + // never is a subtype of everything (including ArrayAccess), so without + // this short-circuit the fetch would be resolved through offsetGet() and + // produce an *ERROR* type instead of the expected never. + return $offsetAccessibleType; + } + if ( !$offsetAccessibleType->isArray()->yes() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 8b54b83b213..2ed3ae3842e 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1947,7 +1947,10 @@ private function optimizeScalarType(Type $type): Type public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResult { if ($leftType instanceof NeverType || $rightType instanceof NeverType) { - return new TypeResult(new ConstantBooleanType(false), []); + // A never-typed operand has no value to compare, so the result is + // undecided. This mirrors how never behaves as a boolean condition and + // keeps always-true/false rules from piling onto already-unreachable code. + return new TypeResult(new BooleanType(), []); } if ($leftType instanceof ConstantScalarType && $rightType instanceof ConstantScalarType) { diff --git a/tests/PHPStan/Analyser/data/bug-9307.php b/tests/PHPStan/Analyser/data/bug-9307.php index 69158aa7591..0b6ce3becc5 100644 --- a/tests/PHPStan/Analyser/data/bug-9307.php +++ b/tests/PHPStan/Analyser/data/bug-9307.php @@ -31,7 +31,7 @@ public function test(): void } } - assertType('array<*ERROR*>', $objects); // could be array + assertType('array', $objects); $this->acceptObjects($objects); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14281.php b/tests/PHPStan/Analyser/nsrt/bug-14281.php new file mode 100644 index 00000000000..4040b51a55a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14281.php @@ -0,0 +1,37 @@ + 'value'], + ]; + + assert($array[0] === null); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $array); + + // $array[1] is 0, so this assertion can never hold and collapses the array + assert($array[1] === null); + assertType('*NEVER*', $array); + + // offset access on a never array must stay never instead of becoming *ERROR* + assertType('*NEVER*', $array[2]); + assert($array[2] === null); + assertType('*NEVER*', $array); +} + +function neverVariable(int $i): void +{ + if ($i !== $i) { + assertType('*NEVER*', $i); + assertType('bool', $i === null); + assertType('bool', $i !== null); + } +} diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 0d476571c8a..bd1b3664e41 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -698,10 +698,6 @@ public static function dataLastMatchArm(): iterable 36, 'Remove remaining cases below this one and this error will disappear too.', ], - [ - "Strict comparison using === between *NEVER* and 'ccc' will always evaluate to false.", - 38, - ], [ "Strict comparison using === between 'bbb' and 'bbb' will always evaluate to true.", 46, @@ -731,10 +727,6 @@ public static function dataLastMatchArm(): iterable "Strict comparison using === between 'bbb' and 'bbb' will always evaluate to true.", 36, ], - [ - "Strict comparison using === between *NEVER* and 'ccc' will always evaluate to false.", - 38, - ], [ "Strict comparison using === between 'bbb' and 'bbb' will always evaluate to true.", 46, @@ -1275,4 +1267,22 @@ public function testBug14847(): void ]); } + public function testBug14281(): void + { + $this->analyse([__DIR__ . '/data/bug-14281.php'], [ + [ + 'Strict comparison using === between null and null will always evaluate to true.', + 15, + ], + [ + 'Strict comparison using === between 0 and null will always evaluate to false.', + 16, + ], + [ + 'Strict comparison using !== between int and int will always evaluate to false.', + 25, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14281.php b/tests/PHPStan/Rules/Comparison/data/bug-14281.php new file mode 100644 index 00000000000..f45ca373f88 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14281.php @@ -0,0 +1,30 @@ + 'value'], + ]; + + assert($array[0] === null); + assert($array[1] === null); + // everything below is unreachable, the comparisons must not be reported + assert($array[2] === null); + assert($array[3] === null); + assert($array[4] === null); +} + +function neverOperand(int $i): void +{ + if ($i !== $i) { + // $i is never here + $a = ($i === null); + $b = ($i !== null); + } +} From 7edd49a3695372c1d943bd58acf6deaf9e37998b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 18 Jul 2026 13:01:48 +0000 Subject: [PATCH 2/2] Keep constant false for identical comparison with a never operand Revert resolveIdenticalType() to return ConstantBooleanType(false) when either operand is never, as requested in review. The always-false/true reports on never operands (including *NEVER* === 'ccc' in the last match arm) are kept. The array offset access on never -> never fix in ArrayDimFetchHandler is unaffected. Co-Authored-By: Claude Opus 4.8 --- .../InitializerExprTypeResolver.php | 5 +-- tests/PHPStan/Analyser/nsrt/bug-14281.php | 4 +-- ...rictComparisonOfDifferentTypesRuleTest.php | 32 +++++++++++++++++-- .../Rules/Comparison/data/bug-14281.php | 6 ++-- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 2ed3ae3842e..8b54b83b213 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1947,10 +1947,7 @@ private function optimizeScalarType(Type $type): Type public function resolveIdenticalType(Type $leftType, Type $rightType): TypeResult { if ($leftType instanceof NeverType || $rightType instanceof NeverType) { - // A never-typed operand has no value to compare, so the result is - // undecided. This mirrors how never behaves as a boolean condition and - // keeps always-true/false rules from piling onto already-unreachable code. - return new TypeResult(new BooleanType(), []); + return new TypeResult(new ConstantBooleanType(false), []); } if ($leftType instanceof ConstantScalarType && $rightType instanceof ConstantScalarType) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14281.php b/tests/PHPStan/Analyser/nsrt/bug-14281.php index 4040b51a55a..8085ef7c01d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14281.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14281.php @@ -31,7 +31,7 @@ function neverVariable(int $i): void { if ($i !== $i) { assertType('*NEVER*', $i); - assertType('bool', $i === null); - assertType('bool', $i !== null); + assertType('false', $i === null); + assertType('true', $i !== null); } } diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index bd1b3664e41..a684f6200c8 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -698,6 +698,10 @@ public static function dataLastMatchArm(): iterable 36, 'Remove remaining cases below this one and this error will disappear too.', ], + [ + "Strict comparison using === between *NEVER* and 'ccc' will always evaluate to false.", + 38, + ], [ "Strict comparison using === between 'bbb' and 'bbb' will always evaluate to true.", 46, @@ -727,6 +731,10 @@ public static function dataLastMatchArm(): iterable "Strict comparison using === between 'bbb' and 'bbb' will always evaluate to true.", 36, ], + [ + "Strict comparison using === between *NEVER* and 'ccc' will always evaluate to false.", + 38, + ], [ "Strict comparison using === between 'bbb' and 'bbb' will always evaluate to true.", 46, @@ -1276,11 +1284,31 @@ public function testBug14281(): void ], [ 'Strict comparison using === between 0 and null will always evaluate to false.', - 16, + 17, + ], + [ + 'Strict comparison using === between *NEVER* and null will always evaluate to false.', + 20, + ], + [ + 'Strict comparison using === between *NEVER* and null will always evaluate to false.', + 21, + ], + [ + 'Strict comparison using === between *NEVER* and null will always evaluate to false.', + 22, ], [ 'Strict comparison using !== between int and int will always evaluate to false.', - 25, + 27, + ], + [ + 'Strict comparison using === between *NEVER* and null will always evaluate to false.', + 29, + ], + [ + 'Strict comparison using !== between *NEVER* and null will always evaluate to true.', + 30, ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14281.php b/tests/PHPStan/Rules/Comparison/data/bug-14281.php index f45ca373f88..62a4490ba44 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14281.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14281.php @@ -13,8 +13,10 @@ function test(): void ]; assert($array[0] === null); + // $array[1] is 0, so this assertion collapses the array to never assert($array[1] === null); - // everything below is unreachable, the comparisons must not be reported + // offset access on the never array stays never (instead of *ERROR*), + // so the comparisons below are reported as impossible assert($array[2] === null); assert($array[3] === null); assert($array[4] === null); @@ -23,7 +25,7 @@ function test(): void function neverOperand(int $i): void { if ($i !== $i) { - // $i is never here + // $i is never here, so the comparisons are reported as impossible $a = ($i === null); $b = ($i !== null); }