From c67de607eb50368168df6443bc6319843d39ba65 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 25 May 2026 21:10:59 +0000 Subject: [PATCH 1/4] Preserve `BenevolentUnionType` for `IntegerRangeType` division results in `integerRangeMath` - Return benevolent union from `integerRangeMath` when division of two integer types produces `int|float`, matching the existing behavior for plain `int / int` division - Propagate benevolence through union-combining code in both `integerRangeMath` (operand union handling) and `resolveCommonMath` (left number type union handling) by tracking whether any part returned a `BenevolentUnionType` - Simplify the cross-zero range-splitting blocks to use `TypeUtils::toBenevolentUnion()` instead of an equality check against `UnionType([int, float])`, which missed bounded integer range results - Update test expectations in integer-range-types.php, div-by-zero.php, math.php, and binary.php to reflect the corrected benevolent union types --- .../InitializerExprTypeResolver.php | 32 +++++++------- tests/PHPStan/Analyser/nsrt/bug-9724.php | 44 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/div-by-zero.php | 6 +-- .../Analyser/nsrt/integer-range-types.php | 40 ++++++++--------- tests/PHPStan/Analyser/nsrt/math.php | 8 ++-- 5 files changed, 88 insertions(+), 42 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9724.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index d3f4e7abf54..9c72eab1149 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2034,18 +2034,23 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri ); } elseif ($leftNumberType instanceof UnionType) { $unionParts = []; + $hasBenevolentPart = false; foreach ($leftNumberType->getTypes() as $type) { $numberType = $type->toNumber(); if ($numberType instanceof IntegerRangeType || $numberType instanceof ConstantIntegerType) { - $unionParts[] = $this->integerRangeMath($numberType, $expr, $rightNumberType); + $part = $this->integerRangeMath($numberType, $expr, $rightNumberType); + if ($part instanceof BenevolentUnionType) { + $hasBenevolentPart = true; + } + $unionParts[] = $part; } else { $unionParts[] = $numberType; } } $union = TypeCombinator::union(...$unionParts); - if ($leftNumberType instanceof BenevolentUnionType) { + if ($leftNumberType instanceof BenevolentUnionType || $hasBenevolentPart) { return TypeUtils::toBenevolentUnion($union)->toNumber(); } @@ -2114,18 +2119,23 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T if ($operand instanceof UnionType) { $unionParts = []; + $hasBenevolentPart = false; foreach ($operand->getTypes() as $type) { $numberType = $type->toNumber(); if ($numberType instanceof IntegerRangeType || $numberType instanceof ConstantIntegerType) { - $unionParts[] = $this->integerRangeMath($range, $node, $numberType); + $part = $this->integerRangeMath($range, $node, $numberType); + if ($part instanceof BenevolentUnionType) { + $hasBenevolentPart = true; + } + $unionParts[] = $part; } else { $unionParts[] = $type->toNumber(); } } $union = TypeCombinator::union(...$unionParts); - if ($operand instanceof BenevolentUnionType) { + if ($operand instanceof BenevolentUnionType || $hasBenevolentPart) { return TypeUtils::toBenevolentUnion($union)->toNumber(); } @@ -2245,11 +2255,7 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T $this->integerRangeMath($range, $node, $positiveOperand), )->toNumber(); - if ($result->equals(new UnionType([new IntegerType(), new FloatType()]))) { - return new BenevolentUnionType([new IntegerType(), new FloatType()]); - } - - return $result; + return TypeUtils::toBenevolentUnion($result); } if ( ($rangeMin < 0 || $rangeMin === null) @@ -2265,11 +2271,7 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T $this->integerRangeMath($positiveRange, $node, $operand), )->toNumber(); - if ($result->equals(new UnionType([new IntegerType(), new FloatType()]))) { - return new BenevolentUnionType([new IntegerType(), new FloatType()]); - } - - return $result; + return TypeUtils::toBenevolentUnion($result); } $rangeMinSign = ($rangeMin ?? -INF) <=> 0; @@ -2313,7 +2315,7 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T return new BenevolentUnionType([new IntegerType(), new FloatType()]); } - return TypeCombinator::union(IntegerRangeType::fromInterval($min, $max), new FloatType()); + return TypeUtils::toBenevolentUnion(TypeCombinator::union(IntegerRangeType::fromInterval($min, $max), new FloatType())); } elseif ($node instanceof Expr\BinaryOp\ShiftLeft) { if (!$operand instanceof ConstantIntegerType) { return new IntegerType(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-9724.php b/tests/PHPStan/Analyser/nsrt/bug-9724.php new file mode 100644 index 00000000000..c5a074eb9ac --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9724.php @@ -0,0 +1,44 @@ +|int<2, max>)', ($offset / $limit) + 1); + } + } + + /** @param int<-2, 2> $offsetRange */ + public function withRange(int $limit, int $offset, int $offsetRange): void + { + if ($limit) { + assertType('(float|int)', $offset / $limit); + assertType('(float|int<-2, 2>)', $offsetRange / $limit); + } + } + + private function expectInt(int $page): void + { + } + + public function originalIssue(?int $limit, int $offset = 0): void + { + if ($limit && $offset && (0 === ($offset % $limit))) { + $this->expectInt(($offset / $limit) + 1); + } + } +} diff --git a/tests/PHPStan/Analyser/nsrt/div-by-zero.php b/tests/PHPStan/Analyser/nsrt/div-by-zero.php index ad027888bca..541084ca356 100644 --- a/tests/PHPStan/Analyser/nsrt/div-by-zero.php +++ b/tests/PHPStan/Analyser/nsrt/div-by-zero.php @@ -13,9 +13,9 @@ class Foo */ public function doFoo(int $range1, int $range2, int $int): void { - assertType('float|int<1, 5>', 5 / $range1); - assertType('float|int<-5, -1>', 5 / $range2); - assertType('float|int', $range1 / $range2); + assertType('(float|int<1, 5>)', 5 / $range1); + assertType('(float|int<-5, -1>)', 5 / $range2); + assertType('(float|int)', $range1 / $range2); assertType('(float|int)', 5 / $int); assertType('*ERROR*', 5 / 0); diff --git a/tests/PHPStan/Analyser/nsrt/integer-range-types.php b/tests/PHPStan/Analyser/nsrt/integer-range-types.php index 8adf56f1c10..17dbee6a856 100644 --- a/tests/PHPStan/Analyser/nsrt/integer-range-types.php +++ b/tests/PHPStan/Analyser/nsrt/integer-range-types.php @@ -218,70 +218,70 @@ public function math($i, $j, $z, $pi, $r1, $r2, $r3, $rMin, $rMax, $x, $y) { assertType('int<2, 13>', $r1 + $j); assertType('int<-2, 9>', $r1 - $j); assertType('int<1, 30>', $r1 * $j); - assertType('float|int<1, 10>', $r1 / $j); + assertType('(float|int<1, 10>)', $r1 / $j); assertType('int', $rMin * $j); assertType('int<5, max>', $rMax * $j); assertType('int<2, 13>', $j + $r1); assertType('int<-9, 2>', $j - $r1); assertType('int<1, 30>', $j * $r1); - assertType('float|int<1, 3>', $j / $r1); + assertType('(float|int<1, 3>)', $j / $r1); assertType('int', $j * $rMin); assertType('int<5, max>', $j * $rMax); assertType('int<-19, -10>|int<2, 13>', $r1 + $z); assertType('int<-2, 9>|int<21, 30>', $r1 - $z); assertType('int<-200, -20>|int<1, 30>', $r1 * $z); - assertType('float|int<1, 10>', $r1 / $z); + assertType('(float|int<1, 10>)', $r1 / $z); assertType('int', $rMin * $z); assertType('int|int<5, max>', $rMax * $z); assertType('int<2, max>', $pi + 1); assertType('int<-1, max>', $pi - 2); assertType('int<2, max>', $pi * 2); - assertType('float|int<1, max>', $pi / 2); + assertType('(float|int<1, max>)', $pi / 2); assertType('int<2, max>', 1 + $pi); assertType('int', 2 - $pi); assertType('int<2, max>', 2 * $pi); - assertType('float|int<1, 2>', 2 / $pi); + assertType('(float|int<1, 2>)', 2 / $pi); assertType('int<5, 14>', $r1 + 4); assertType('int<-3, 6>', $r1 - 4); assertType('int<4, 40>', $r1 * 4); - assertType('float|int<1, 2>', $r1 / 4); + assertType('(float|int<1, 2>)', $r1 / 4); assertType('int<9, max>', $rMax + 4); assertType('int<1, max>', $rMax - 4); assertType('int<20, max>', $rMax * 4); - assertType('float|int<2, max>', $rMax / 4); + assertType('(float|int<2, max>)', $rMax / 4); assertType('int<6, 20>', $r1 + $r2); assertType('int<-9, 5>', $r1 - $r2); assertType('int<5, 100>', $r1 * $r2); - assertType('float|int<1, 2>', $r1 / $r2); + assertType('(float|int<1, 2>)', $r1 / $r2); assertType('int<-99, 19>', $r1 - $r3); assertType('int', $r1 + $rMin); assertType('int<-4, max>', $r1 - $rMin); assertType('int', $r1 * $rMin); - assertType('float|int<-10, -1>|int<1, 10>', $r1 / $rMin); + assertType('(float|int<-10, -1>|int<1, 10>)', $r1 / $rMin); assertType('int', $rMin + $r1); assertType('int', $rMin - $r1); assertType('int', $rMin * $r1); - assertType('float|int', $rMin / $r1); + assertType('(float|int)', $rMin / $r1); assertType('int<6, max>', $r1 + $rMax); assertType('int', $r1 - $rMax); assertType('int<5, max>', $r1 * $rMax); - assertType('float|int<1, 2>', $r1 / $rMax); + assertType('(float|int<1, 2>)', $r1 / $rMax); assertType('int<6, max>', $rMax + $r1); assertType('int<-5, max>', $rMax - $r1); assertType('int<5, max>', $rMax * $r1); - assertType('float|int<1, max>', $rMax / $r1); + assertType('(float|int<1, max>)', $rMax / $r1); assertType('5|10|15|20|30', $x / $y); - assertType('float|int<1, max>', $rMax / $rMax); + assertType('(float|int<1, max>)', $rMax / $rMax); assertType('(float|int)', $rMin / $rMin); } @@ -292,9 +292,9 @@ public function math($i, $j, $z, $pi, $r1, $r2, $r3, $rMin, $rMax, $x, $y) { * @param int<2, 4> $d */ function divisionLoosesInformation(int $a, int $b, int $c, int $d): void { - assertType('float|int<0, max>', $a / $b); - assertType('float|int<8, 16>', $c / 2); - assertType('float|int<4, 16>', $c / $d); + assertType('(float|int<0, max>)', $a / $b); + assertType('(float|int<8, 16>)', $c / 2); + assertType('(float|int<4, 16>)', $c / $d); } /** @@ -311,11 +311,11 @@ public function maximaInversion($rMin, $rMax) { assertType('int<-5, max>', $rMin * -1); assertType('int', $rMax * -2); - assertType('-1|1|float', -1 / $rMin); + assertType('(-1|1|float)', -1 / $rMin); assertType('float', -2 / $rMax); - assertType('float|int<-5, max>', $rMin / -1); - assertType('float|int', $rMax / -2); + assertType('(float|int<-5, max>)', $rMin / -1); + assertType('(float|int)', $rMax / -2); } /** @@ -343,7 +343,7 @@ public function sayHello($p, $u): void assertType('int<-2, 4>', $p + $u); assertType('int<-3, 3>', $p - $u); assertType('int<-2, 4>', $p * $u); - assertType('float|int<-2, 2>', $p / $u); + assertType('(float|int<-2, 2>)', $p / $u); } /** diff --git a/tests/PHPStan/Analyser/nsrt/math.php b/tests/PHPStan/Analyser/nsrt/math.php index 9d12809783e..853889e123c 100644 --- a/tests/PHPStan/Analyser/nsrt/math.php +++ b/tests/PHPStan/Analyser/nsrt/math.php @@ -81,9 +81,9 @@ public function doIpsum(int $newLevel): void $min = min(30, $newLevel); assertType('int', $min); $minDivFive = $min / 5; - assertType('float|int', $minDivFive); + assertType('(float|int)', $minDivFive); $volume = 0x10000000 * $minDivFive; - assertType('float|int', $volume); + assertType('(float|int)', $volume); } public function doDolor(int $i): void @@ -91,8 +91,8 @@ public function doDolor(int $i): void $chunks = min(200, $i); assertType('int', $chunks); $divThirty = $chunks / 30; - assertType('float|int', $divThirty); - assertType('float|int', $divThirty + 3); + assertType('(float|int)', $divThirty); + assertType('(float|int)', $divThirty + 3); } public function doSit(int $i, int $j): void From 902413585941ccf72b3300daf53f5860ce7d52a7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 21:35:52 +0000 Subject: [PATCH 2/4] Narrow division to int when modulo is known to be zero Instead of making all integer range divisions return benevolent unions, detect when the scope knows that `$a % $b === 0` and narrow `$a / $b` to just the integer type (removing float from the union). This is more precise: it only removes float when mathematically guaranteed, rather than broadly making all integer range divisions benevolent. The check works by constructing a synthetic modulo expression with the same operands and querying the scope for its type. When a condition like `0 === ($a % $b)` has narrowed the modulo to ConstantIntegerType(0), the division result is guaranteed to be int. Reverts the BenevolentUnionType propagation changes from the previous commit since the modulo narrowing is the correct fix. Co-Authored-By: Claude Opus 4.6 --- .../InitializerExprTypeResolver.php | 46 ++++++++++++------- tests/PHPStan/Analyser/nsrt/bug-9724.php | 8 ++-- tests/PHPStan/Analyser/nsrt/div-by-zero.php | 6 +-- .../Analyser/nsrt/integer-range-types.php | 40 ++++++++-------- tests/PHPStan/Analyser/nsrt/math.php | 8 ++-- 5 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 9c72eab1149..97ac6041bae 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1240,6 +1240,20 @@ public function getDivType(Expr $left, Expr $right, callable $getTypeCallback): $leftType = $getTypeCallback($left); $rightType = $getTypeCallback($right); + $result = $this->getDivTypeFromTypes($left, $right, $leftType, $rightType); + + if ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) { + $modType = $getTypeCallback(new BinaryOp\Mod($left, $right)); + if ($modType->isInteger()->yes() && (new ConstantIntegerType(0))->isSuperTypeOf($modType)->yes()) { + return TypeCombinator::remove($result, new FloatType()); + } + } + + return $result; + } + + private function getDivTypeFromTypes(Expr $left, Expr $right, Type $leftType, Type $rightType): Type + { $leftTypes = $leftType->getConstantScalarTypes(); $rightTypes = $rightType->getConstantScalarTypes(); $leftTypesCount = count($leftTypes); @@ -2034,23 +2048,18 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri ); } elseif ($leftNumberType instanceof UnionType) { $unionParts = []; - $hasBenevolentPart = false; foreach ($leftNumberType->getTypes() as $type) { $numberType = $type->toNumber(); if ($numberType instanceof IntegerRangeType || $numberType instanceof ConstantIntegerType) { - $part = $this->integerRangeMath($numberType, $expr, $rightNumberType); - if ($part instanceof BenevolentUnionType) { - $hasBenevolentPart = true; - } - $unionParts[] = $part; + $unionParts[] = $this->integerRangeMath($numberType, $expr, $rightNumberType); } else { $unionParts[] = $numberType; } } $union = TypeCombinator::union(...$unionParts); - if ($leftNumberType instanceof BenevolentUnionType || $hasBenevolentPart) { + if ($leftNumberType instanceof BenevolentUnionType) { return TypeUtils::toBenevolentUnion($union)->toNumber(); } @@ -2119,23 +2128,18 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T if ($operand instanceof UnionType) { $unionParts = []; - $hasBenevolentPart = false; foreach ($operand->getTypes() as $type) { $numberType = $type->toNumber(); if ($numberType instanceof IntegerRangeType || $numberType instanceof ConstantIntegerType) { - $part = $this->integerRangeMath($range, $node, $numberType); - if ($part instanceof BenevolentUnionType) { - $hasBenevolentPart = true; - } - $unionParts[] = $part; + $unionParts[] = $this->integerRangeMath($range, $node, $numberType); } else { $unionParts[] = $type->toNumber(); } } $union = TypeCombinator::union(...$unionParts); - if ($operand instanceof BenevolentUnionType || $hasBenevolentPart) { + if ($operand instanceof BenevolentUnionType) { return TypeUtils::toBenevolentUnion($union)->toNumber(); } @@ -2255,7 +2259,11 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T $this->integerRangeMath($range, $node, $positiveOperand), )->toNumber(); - return TypeUtils::toBenevolentUnion($result); + if ($result->equals(new UnionType([new IntegerType(), new FloatType()]))) { + return new BenevolentUnionType([new IntegerType(), new FloatType()]); + } + + return $result; } if ( ($rangeMin < 0 || $rangeMin === null) @@ -2271,7 +2279,11 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T $this->integerRangeMath($positiveRange, $node, $operand), )->toNumber(); - return TypeUtils::toBenevolentUnion($result); + if ($result->equals(new UnionType([new IntegerType(), new FloatType()]))) { + return new BenevolentUnionType([new IntegerType(), new FloatType()]); + } + + return $result; } $rangeMinSign = ($rangeMin ?? -INF) <=> 0; @@ -2315,7 +2327,7 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T return new BenevolentUnionType([new IntegerType(), new FloatType()]); } - return TypeUtils::toBenevolentUnion(TypeCombinator::union(IntegerRangeType::fromInterval($min, $max), new FloatType())); + return TypeCombinator::union(IntegerRangeType::fromInterval($min, $max), new FloatType()); } elseif ($node instanceof Expr\BinaryOp\ShiftLeft) { if (!$operand instanceof ConstantIntegerType) { return new IntegerType(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-9724.php b/tests/PHPStan/Analyser/nsrt/bug-9724.php index c5a074eb9ac..9c48e145368 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9724.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9724.php @@ -11,14 +11,16 @@ class HelloWorld public function thisWorks(?int $limit, int $offset = 0): void { if ($limit && 0 === ($offset % $limit)) { - assertType('(float|int)', ($offset / $limit) + 1); + assertType('int', $offset / $limit); + assertType('int', ($offset / $limit) + 1); } } public function thisDoesntWork(?int $limit, int $offset = 0): void { if ($limit && $offset && (0 === ($offset % $limit))) { - assertType('(float|int|int<2, max>)', ($offset / $limit) + 1); + assertType('int|int<1, max>', $offset / $limit); + assertType('int|int<2, max>', ($offset / $limit) + 1); } } @@ -27,7 +29,7 @@ public function withRange(int $limit, int $offset, int $offsetRange): void { if ($limit) { assertType('(float|int)', $offset / $limit); - assertType('(float|int<-2, 2>)', $offsetRange / $limit); + assertType('float|int<-2, 2>', $offsetRange / $limit); } } diff --git a/tests/PHPStan/Analyser/nsrt/div-by-zero.php b/tests/PHPStan/Analyser/nsrt/div-by-zero.php index 541084ca356..ad027888bca 100644 --- a/tests/PHPStan/Analyser/nsrt/div-by-zero.php +++ b/tests/PHPStan/Analyser/nsrt/div-by-zero.php @@ -13,9 +13,9 @@ class Foo */ public function doFoo(int $range1, int $range2, int $int): void { - assertType('(float|int<1, 5>)', 5 / $range1); - assertType('(float|int<-5, -1>)', 5 / $range2); - assertType('(float|int)', $range1 / $range2); + assertType('float|int<1, 5>', 5 / $range1); + assertType('float|int<-5, -1>', 5 / $range2); + assertType('float|int', $range1 / $range2); assertType('(float|int)', 5 / $int); assertType('*ERROR*', 5 / 0); diff --git a/tests/PHPStan/Analyser/nsrt/integer-range-types.php b/tests/PHPStan/Analyser/nsrt/integer-range-types.php index 17dbee6a856..8adf56f1c10 100644 --- a/tests/PHPStan/Analyser/nsrt/integer-range-types.php +++ b/tests/PHPStan/Analyser/nsrt/integer-range-types.php @@ -218,70 +218,70 @@ public function math($i, $j, $z, $pi, $r1, $r2, $r3, $rMin, $rMax, $x, $y) { assertType('int<2, 13>', $r1 + $j); assertType('int<-2, 9>', $r1 - $j); assertType('int<1, 30>', $r1 * $j); - assertType('(float|int<1, 10>)', $r1 / $j); + assertType('float|int<1, 10>', $r1 / $j); assertType('int', $rMin * $j); assertType('int<5, max>', $rMax * $j); assertType('int<2, 13>', $j + $r1); assertType('int<-9, 2>', $j - $r1); assertType('int<1, 30>', $j * $r1); - assertType('(float|int<1, 3>)', $j / $r1); + assertType('float|int<1, 3>', $j / $r1); assertType('int', $j * $rMin); assertType('int<5, max>', $j * $rMax); assertType('int<-19, -10>|int<2, 13>', $r1 + $z); assertType('int<-2, 9>|int<21, 30>', $r1 - $z); assertType('int<-200, -20>|int<1, 30>', $r1 * $z); - assertType('(float|int<1, 10>)', $r1 / $z); + assertType('float|int<1, 10>', $r1 / $z); assertType('int', $rMin * $z); assertType('int|int<5, max>', $rMax * $z); assertType('int<2, max>', $pi + 1); assertType('int<-1, max>', $pi - 2); assertType('int<2, max>', $pi * 2); - assertType('(float|int<1, max>)', $pi / 2); + assertType('float|int<1, max>', $pi / 2); assertType('int<2, max>', 1 + $pi); assertType('int', 2 - $pi); assertType('int<2, max>', 2 * $pi); - assertType('(float|int<1, 2>)', 2 / $pi); + assertType('float|int<1, 2>', 2 / $pi); assertType('int<5, 14>', $r1 + 4); assertType('int<-3, 6>', $r1 - 4); assertType('int<4, 40>', $r1 * 4); - assertType('(float|int<1, 2>)', $r1 / 4); + assertType('float|int<1, 2>', $r1 / 4); assertType('int<9, max>', $rMax + 4); assertType('int<1, max>', $rMax - 4); assertType('int<20, max>', $rMax * 4); - assertType('(float|int<2, max>)', $rMax / 4); + assertType('float|int<2, max>', $rMax / 4); assertType('int<6, 20>', $r1 + $r2); assertType('int<-9, 5>', $r1 - $r2); assertType('int<5, 100>', $r1 * $r2); - assertType('(float|int<1, 2>)', $r1 / $r2); + assertType('float|int<1, 2>', $r1 / $r2); assertType('int<-99, 19>', $r1 - $r3); assertType('int', $r1 + $rMin); assertType('int<-4, max>', $r1 - $rMin); assertType('int', $r1 * $rMin); - assertType('(float|int<-10, -1>|int<1, 10>)', $r1 / $rMin); + assertType('float|int<-10, -1>|int<1, 10>', $r1 / $rMin); assertType('int', $rMin + $r1); assertType('int', $rMin - $r1); assertType('int', $rMin * $r1); - assertType('(float|int)', $rMin / $r1); + assertType('float|int', $rMin / $r1); assertType('int<6, max>', $r1 + $rMax); assertType('int', $r1 - $rMax); assertType('int<5, max>', $r1 * $rMax); - assertType('(float|int<1, 2>)', $r1 / $rMax); + assertType('float|int<1, 2>', $r1 / $rMax); assertType('int<6, max>', $rMax + $r1); assertType('int<-5, max>', $rMax - $r1); assertType('int<5, max>', $rMax * $r1); - assertType('(float|int<1, max>)', $rMax / $r1); + assertType('float|int<1, max>', $rMax / $r1); assertType('5|10|15|20|30', $x / $y); - assertType('(float|int<1, max>)', $rMax / $rMax); + assertType('float|int<1, max>', $rMax / $rMax); assertType('(float|int)', $rMin / $rMin); } @@ -292,9 +292,9 @@ public function math($i, $j, $z, $pi, $r1, $r2, $r3, $rMin, $rMax, $x, $y) { * @param int<2, 4> $d */ function divisionLoosesInformation(int $a, int $b, int $c, int $d): void { - assertType('(float|int<0, max>)', $a / $b); - assertType('(float|int<8, 16>)', $c / 2); - assertType('(float|int<4, 16>)', $c / $d); + assertType('float|int<0, max>', $a / $b); + assertType('float|int<8, 16>', $c / 2); + assertType('float|int<4, 16>', $c / $d); } /** @@ -311,11 +311,11 @@ public function maximaInversion($rMin, $rMax) { assertType('int<-5, max>', $rMin * -1); assertType('int', $rMax * -2); - assertType('(-1|1|float)', -1 / $rMin); + assertType('-1|1|float', -1 / $rMin); assertType('float', -2 / $rMax); - assertType('(float|int<-5, max>)', $rMin / -1); - assertType('(float|int)', $rMax / -2); + assertType('float|int<-5, max>', $rMin / -1); + assertType('float|int', $rMax / -2); } /** @@ -343,7 +343,7 @@ public function sayHello($p, $u): void assertType('int<-2, 4>', $p + $u); assertType('int<-3, 3>', $p - $u); assertType('int<-2, 4>', $p * $u); - assertType('(float|int<-2, 2>)', $p / $u); + assertType('float|int<-2, 2>', $p / $u); } /** diff --git a/tests/PHPStan/Analyser/nsrt/math.php b/tests/PHPStan/Analyser/nsrt/math.php index 853889e123c..9d12809783e 100644 --- a/tests/PHPStan/Analyser/nsrt/math.php +++ b/tests/PHPStan/Analyser/nsrt/math.php @@ -81,9 +81,9 @@ public function doIpsum(int $newLevel): void $min = min(30, $newLevel); assertType('int', $min); $minDivFive = $min / 5; - assertType('(float|int)', $minDivFive); + assertType('float|int', $minDivFive); $volume = 0x10000000 * $minDivFive; - assertType('(float|int)', $volume); + assertType('float|int', $volume); } public function doDolor(int $i): void @@ -91,8 +91,8 @@ public function doDolor(int $i): void $chunks = min(200, $i); assertType('int', $chunks); $divThirty = $chunks / 30; - assertType('(float|int)', $divThirty); - assertType('(float|int)', $divThirty + 3); + assertType('float|int', $divThirty); + assertType('float|int', $divThirty + 3); } public function doSit(int $i, int $j): void From ebf4d35fa6019b2e26ea0e77c1a37eec76cac76b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:19:05 +0000 Subject: [PATCH 3/4] Add rule test for bug-9724 to prove the fix resolves the reported error Co-Authored-By: Claude Opus 4.6 --- .../Rules/Methods/CallMethodsRuleTest.php | 8 ++++++++ tests/PHPStan/Rules/Methods/data/bug-9724.php | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-9724.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index d7a4d6766d8..7dec818cc86 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4106,4 +4106,12 @@ public function testBug14596(): void ]); } + public function testBug9724(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-9724.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-9724.php b/tests/PHPStan/Rules/Methods/data/bug-9724.php new file mode 100644 index 00000000000..ac09822212f --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9724.php @@ -0,0 +1,19 @@ +expectInt(($offset / $limit) + 1); + } + } +} From c865717db7e7f4be7582a5e73e29f426c4d43a0e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 26 May 2026 21:23:27 +0200 Subject: [PATCH 4/4] Update bug-9724.php --- tests/PHPStan/Analyser/nsrt/bug-9724.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-9724.php b/tests/PHPStan/Analyser/nsrt/bug-9724.php index 9c48e145368..2658a5e6a13 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9724.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9724.php @@ -32,15 +32,4 @@ public function withRange(int $limit, int $offset, int $offsetRange): void assertType('float|int<-2, 2>', $offsetRange / $limit); } } - - private function expectInt(int $page): void - { - } - - public function originalIssue(?int $limit, int $offset = 0): void - { - if ($limit && $offset && (0 === ($offset % $limit))) { - $this->expectInt(($offset / $limit) + 1); - } - } }