From 2c18f1218eb1ec8ee0ed47ef74170e625fd901f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:22:01 +0000 Subject: [PATCH] Fix match(true) exhaustiveness for complementary integer comparisons - Added resolveComplementaryComparison() method to MutatingScope that checks if the complement of a comparison expression has a stored type in the scope - When resolving $a >= $b, if $a < $b is already stored as false (or true), the complement is returned, allowing match(true) to detect exhaustive arms - Handles all four comparison pairs: < / >=, <= / >, > / <=, >= / < - New regression test in tests/PHPStan/Rules/Comparison/data/bug-5610.php Closes https://github.com/phpstan/phpstan/issues/5610 --- src/Analyser/MutatingScope.php | 30 ++++++++++++++--- .../Comparison/MatchExpressionRuleTest.php | 6 ++++ .../Rules/Comparison/data/bug-5610.php | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-5610.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a26a86a066..6378816e2b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -987,19 +987,23 @@ private function resolveType(string $exprString, Expr $node): Type } if ($node instanceof Expr\BinaryOp\Smaller) { - return $this->getType($node->left)->isSmallerThan($this->getType($node->right), $this->phpVersion)->toBooleanType(); + return $this->resolveComplementaryComparison($node, new Expr\BinaryOp\GreaterOrEqual($node->left, $node->right)) + ?? $this->getType($node->left)->isSmallerThan($this->getType($node->right), $this->phpVersion)->toBooleanType(); } if ($node instanceof Expr\BinaryOp\SmallerOrEqual) { - return $this->getType($node->left)->isSmallerThanOrEqual($this->getType($node->right), $this->phpVersion)->toBooleanType(); + return $this->resolveComplementaryComparison($node, new Expr\BinaryOp\Greater($node->left, $node->right)) + ?? $this->getType($node->left)->isSmallerThanOrEqual($this->getType($node->right), $this->phpVersion)->toBooleanType(); } if ($node instanceof Expr\BinaryOp\Greater) { - return $this->getType($node->right)->isSmallerThan($this->getType($node->left), $this->phpVersion)->toBooleanType(); + return $this->resolveComplementaryComparison($node, new Expr\BinaryOp\SmallerOrEqual($node->left, $node->right)) + ?? $this->getType($node->right)->isSmallerThan($this->getType($node->left), $this->phpVersion)->toBooleanType(); } if ($node instanceof Expr\BinaryOp\GreaterOrEqual) { - return $this->getType($node->right)->isSmallerThanOrEqual($this->getType($node->left), $this->phpVersion)->toBooleanType(); + return $this->resolveComplementaryComparison($node, new Expr\BinaryOp\Smaller($node->left, $node->right)) + ?? $this->getType($node->right)->isSmallerThanOrEqual($this->getType($node->left), $this->phpVersion)->toBooleanType(); } if ($node instanceof Expr\BinaryOp\Equal) { @@ -1609,6 +1613,24 @@ private function getNullsafeShortCircuitingType(Expr $expr, Type $type): Type return $type; } + private function resolveComplementaryComparison(Expr\BinaryOp $node, Expr\BinaryOp $complement): ?Type + { + $complementKey = $this->getNodeKey($complement); + if (!array_key_exists($complementKey, $this->expressionTypes)) { + return null; + } + + $complementType = $this->expressionTypes[$complementKey]->getType(); + if ($complementType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($complementType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return null; + } + private function transformVoidToNull(Type $type, Node $node): Type { if ($node->getAttribute(self::KEEP_VOID_ATTRIBUTE_NAME) === true) { diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index e92bd57639..ee3808a90f 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -446,6 +446,12 @@ public function testBug9534(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug5610(): void + { + $this->analyse([__DIR__ . '/data/bug-5610.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testBug11310(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/bug-5610.php b/tests/PHPStan/Rules/Comparison/data/bug-5610.php new file mode 100644 index 0000000000..2bc4a427a6 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-5610.php @@ -0,0 +1,33 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug5610; + +function foo(int $bar, int $baz): int { + return match (true) { + $bar < $baz => 1, + $bar >= $baz => 2, + }; +} + +function foo3(int $bar, int $baz): int { + return match (true) { + $bar > $baz => 1, + $bar <= $baz => 2, + }; +} + +function foo4(int $bar, int $baz): int { + return match (true) { + $bar <= $baz => 1, + $bar > $baz => 2, + }; +} + +function foo5(int $bar, int $baz): int { + return match (true) { + $bar >= $baz => 1, + $bar < $baz => 2, + }; +}