From d925bdd6a3515b83d767b2860075ee299447e06b Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:40:13 +0000 Subject: [PATCH] Fix phpstan/phpstan#14396: False UnhandledMatchError in foreach with treatPhpDocTypesAsCertain=false - Fixed TypeSpecifier to specify types for both AlwaysRememberedExpr and its unwrapped inner expression when handling always-true/false identity comparisons - Previously, only the unwrapped expression was specified, leaving the AlwaysRememberedExpr type un-narrowed in the match scope - New regression test in tests/PHPStan/Rules/Exceptions/Bug14396Test.php --- src/Analyser/TypeSpecifier.php | 6 ++- .../PHPStan/Rules/Exceptions/Bug14396Test.php | 46 +++++++++++++++++++ tests/PHPStan/Rules/Exceptions/bug-14396.neon | 5 ++ .../Rules/Exceptions/data/bug-14396.php | 45 ++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Exceptions/Bug14396Test.php create mode 100644 tests/PHPStan/Rules/Exceptions/bug-14396.neon create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-14396.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 83974aa3ae2..e4840970540 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2998,12 +2998,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $never = new NeverType(); $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $this->create($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $leftTypes = $this->create($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr) + ->unionWith($this->create($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr)); } else { $leftTypes = $this->create($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $this->create($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $rightTypes = $this->create($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr) + ->unionWith($this->create($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr)); } else { $rightTypes = $this->create($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } diff --git a/tests/PHPStan/Rules/Exceptions/Bug14396Test.php b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php new file mode 100644 index 00000000000..bb0e9c982ed --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php @@ -0,0 +1,46 @@ + + */ +class Bug14396Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new MissingCheckedExceptionInFunctionThrowsRule( + new MissingCheckedExceptionInThrowsCheck(new DefaultExceptionTypeResolver( + self::createReflectionProvider(), + [], + [], + [], + [], + )), + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return false; + } + + #[RequiresPhp('>= 8.1')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/bug-14396.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-14396.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/bug-14396.neon b/tests/PHPStan/Rules/Exceptions/bug-14396.neon new file mode 100644 index 00000000000..feb290057aa --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/bug-14396.neon @@ -0,0 +1,5 @@ +parameters: + treatPhpDocTypesAsCertain: false + exceptions: + check: + missingCheckedExceptionInThrows: true diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-14396.php b/tests/PHPStan/Rules/Exceptions/data/bug-14396.php new file mode 100644 index 00000000000..bf8901f8127 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-14396.php @@ -0,0 +1,45 @@ += 8.1 + +declare(strict_types=1); + +namespace Bug14396; + +enum Status { + case A; + case B; + case C; +} + +class Item { + public function __construct( + public ?Status $status + ) {} +} + +/** +* @param list $list +*/ +function countAFromCollection(array $list): int +{ + $count = 0; + + foreach ($list as $item) { + match ($item->status) { + Status::A => ++$count, + Status::B, + Status::C, + null => null, + }; + } + + return $count; +} + +function countAFromItem(Item $item): ?int { + return match ($item->status) { + Status::A => 1, + Status::B, + Status::C, + null => null, + }; +}