From 76aae52db67101167a3a900999f07433a3a7044b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 04:51:21 +0900 Subject: [PATCH 1/2] Treat the float 2**63 as above every int in IntegerRangeType factories A float compared with PHP_INT_MAX compares against the float 2**63, so `$value > PHP_INT_MAX` is false for exactly 2**63. createAllSmallerThan() and createAllGreaterThanOrEqualTo() then cast 2**63 to int, which emits "not representable as an int" and yields NeverType / int instead of int / NeverType. Use `>=` like the other two factories do. Co-authored-by: Claude Opus 5.5 --- src/Type/IntegerRangeType.php | 4 +- .../integer-range-float-two-to-the-63.php | 43 +++++++++++ tests/PHPStan/Type/IntegerRangeTypeTest.php | 72 +++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php create mode 100644 tests/PHPStan/Type/IntegerRangeTypeTest.php diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 4b9fb037c3d..0a8b276158b 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -90,7 +90,7 @@ public static function createAllSmallerThan($value): Type return self::fromInterval(null, $value, -1); } - if ($value > PHP_INT_MAX) { + if ($value >= PHP_INT_MAX) { return new IntegerType(); } @@ -160,7 +160,7 @@ public static function createAllGreaterThanOrEqualTo($value): Type return new IntegerType(); } - if ($value > PHP_INT_MAX) { + if ($value >= PHP_INT_MAX) { return new NeverType(); } diff --git a/tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php b/tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php new file mode 100644 index 00000000000..b914af2df34 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php @@ -0,0 +1,43 @@ += 9.2233720368547758E18) { + assertType('*NEVER*', $i); + } else { + assertType('int', $i); + } + assertType('int', $i); +} + +function smallerOrEqual(int $i): void +{ + if ($i <= 9.2233720368547758E18) { + assertType('int', $i); + } else { + assertType('*NEVER*', $i); + } +} + +function greater(int $i): void +{ + if ($i > 9.2233720368547758E18) { + assertType('*NEVER*', $i); + } else { + assertType('int', $i); + } +} diff --git a/tests/PHPStan/Type/IntegerRangeTypeTest.php b/tests/PHPStan/Type/IntegerRangeTypeTest.php new file mode 100644 index 00000000000..d5ee11f0d31 --- /dev/null +++ b/tests/PHPStan/Type/IntegerRangeTypeTest.php @@ -0,0 +1,72 @@ + [ + 9.2233720368547758E18, + 'int', + 'int', + '*NEVER*', + '*NEVER*', + ]; + + // the largest float smaller than 2**63 + yield '2**63 - 1024' => [ + 9.2233720368547748E18, + 'int', + 'int', + 'int<9223372036854774785, max>', + 'int<9223372036854774784, max>', + ]; + + // -2**63 is exactly PHP_INT_MIN + yield '-2**63' => [ + -9.2233720368547758E18, + '*NEVER*', + '-9223372036854775808', + 'int<-9223372036854775807, max>', + 'int', + ]; + + // the largest float smaller than -2**63 + yield '-2**63 - 2048' => [ + -9.2233720368547779E18, + '*NEVER*', + '*NEVER*', + 'int', + 'int', + ]; + } + + #[DataProvider('dataCreateFromFloat')] + public function testCreateFromFloat( + float $value, + string $expectedSmallerThan, + string $expectedSmallerThanOrEqualTo, + string $expectedGreaterThan, + string $expectedGreaterThanOrEqualTo, + ): void + { + $this->assertSame([ + 'createAllSmallerThan' => $expectedSmallerThan, + 'createAllSmallerThanOrEqualTo' => $expectedSmallerThanOrEqualTo, + 'createAllGreaterThan' => $expectedGreaterThan, + 'createAllGreaterThanOrEqualTo' => $expectedGreaterThanOrEqualTo, + ], [ + 'createAllSmallerThan' => IntegerRangeType::createAllSmallerThan($value)->describe(VerbosityLevel::precise()), + 'createAllSmallerThanOrEqualTo' => IntegerRangeType::createAllSmallerThanOrEqualTo($value)->describe(VerbosityLevel::precise()), + 'createAllGreaterThan' => IntegerRangeType::createAllGreaterThan($value)->describe(VerbosityLevel::precise()), + 'createAllGreaterThanOrEqualTo' => IntegerRangeType::createAllGreaterThanOrEqualTo($value)->describe(VerbosityLevel::precise()), + ]); + } + +} From 06683c6e09bb9a9ad6464a99a692d419996be11d Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 09:28:43 +0900 Subject: [PATCH 2/2] Compare float bounds against PHP_INT_MAX + 1.0 in IntegerRangeType The previous commit made createAllSmallerThan() and createAllGreaterThanOrEqualTo() treat a float bound `>= PHP_INT_MAX` as past the int range. That holds on 64-bit builds, where comparing with PHP_INT_MAX converts it to float and rounds it up to 2**63, but on 32-bit builds PHP_INT_MAX is exact as a float: createAllGreaterThanOrEqualTo( 2147483647.0) would return never although the int PHP_INT_MAX satisfies it, and createAllSmallerThan(2147483647.0) would return int instead of int. Decide on the ceil() that is about to be cast instead, compared against PHP_INT_MAX + 1.0, the first float above PHP_INT_MAX, which is exact on both widths (2**63 and 2**31). On 64-bit builds every float at or above 2**52 is integral, so the result there is the same as with `>=`. This is the same code as proposed for 2.3.x in phpstan/phpstan-src#6546. testCreateFromFloat's literals are 64-bit int bounds, so it is skipped on 32-bit hosts; testFloatBounds covers both widths. --- src/Type/IntegerRangeType.php | 17 ++++-- tests/PHPStan/Type/IntegerRangeTypeTest.php | 64 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 0a8b276158b..b41da7851bb 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -33,6 +33,9 @@ class IntegerRangeType extends IntegerType implements CompoundType { + /** The first float above PHP_INT_MAX: 2^63 on 64-bit builds, 2^31 on 32-bit ones, exact on both. */ + private const FLOAT_ABOVE_INT_MAX = PHP_INT_MAX + 1.0; + private function __construct(private ?int $min, private ?int $max) { parent::__construct(); @@ -90,7 +93,11 @@ public static function createAllSmallerThan($value): Type return self::fromInterval(null, $value, -1); } - if ($value >= PHP_INT_MAX) { + // decided on the ceil() about to be cast: comparing $value with PHP_INT_MAX + // converts PHP_INT_MAX to float, which rounds it up to 2^63 on 64-bit builds + // but keeps it exact on 32-bit ones, where 2147483647.0 is still in range + $ceil = ceil($value); + if ($ceil >= self::FLOAT_ABOVE_INT_MAX) { return new IntegerType(); } @@ -98,7 +105,7 @@ public static function createAllSmallerThan($value): Type return new NeverType(); } - return self::fromInterval(null, (int) ceil($value), -1); + return self::fromInterval(null, (int) $ceil, -1); } /** @@ -160,11 +167,13 @@ public static function createAllGreaterThanOrEqualTo($value): Type return new IntegerType(); } - if ($value >= PHP_INT_MAX) { + // decided on the ceil() about to be cast, like createAllSmallerThan() + $ceil = ceil($value); + if ($ceil >= self::FLOAT_ABOVE_INT_MAX) { return new NeverType(); } - return self::fromInterval((int) ceil($value), null); + return self::fromInterval((int) $ceil, null); } public function getMin(): ?int diff --git a/tests/PHPStan/Type/IntegerRangeTypeTest.php b/tests/PHPStan/Type/IntegerRangeTypeTest.php index d5ee11f0d31..e00d2c5224f 100644 --- a/tests/PHPStan/Type/IntegerRangeTypeTest.php +++ b/tests/PHPStan/Type/IntegerRangeTypeTest.php @@ -4,6 +4,12 @@ use PHPStan\Testing\PHPStanTestCase; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; +use function restore_error_handler; +use function set_error_handler; +use const PHP_INT_MAX; +use const PHP_INT_MIN; +use const PHP_INT_SIZE; class IntegerRangeTypeTest extends PHPStanTestCase { @@ -56,6 +62,10 @@ public function testCreateFromFloat( string $expectedGreaterThanOrEqualTo, ): void { + if (PHP_INT_SIZE !== 8) { + $this->markTestSkipped('The bounds are those of a 64-bit int.'); + } + $this->assertSame([ 'createAllSmallerThan' => $expectedSmallerThan, 'createAllSmallerThanOrEqualTo' => $expectedSmallerThanOrEqualTo, @@ -69,4 +79,58 @@ public function testCreateFromFloat( ]); } + public static function dataFloatBounds(): iterable + { + $smallerThan = static fn (float $value): Type => IntegerRangeType::createAllSmallerThan($value); + $smallerThanOrEqualTo = static fn (float $value): Type => IntegerRangeType::createAllSmallerThanOrEqualTo($value); + $greaterThan = static fn (float $value): Type => IntegerRangeType::createAllGreaterThan($value); + $greaterThanOrEqualTo = static fn (float $value): Type => IntegerRangeType::createAllGreaterThanOrEqualTo($value); + + // PHP_INT_MAX + 1.0 is the first float above PHP_INT_MAX on any int width, and + // (float) PHP_INT_MIN is exact on any int width + $aboveMax = PHP_INT_MAX + 1.0; + yield [$smallerThan, $aboveMax, 'int']; + yield [$smallerThanOrEqualTo, $aboveMax, 'int']; + yield [$greaterThan, $aboveMax, '*NEVER*']; + yield [$greaterThanOrEqualTo, $aboveMax, '*NEVER*']; + yield [$smallerThan, (float) PHP_INT_MIN, '*NEVER*']; + yield [$smallerThanOrEqualTo, (float) PHP_INT_MIN, (string) PHP_INT_MIN]; + yield [$greaterThan, (float) PHP_INT_MIN, 'int<' . (PHP_INT_MIN + 1) . ', max>']; + yield [$greaterThanOrEqualTo, (float) PHP_INT_MIN, 'int']; + + // (float) PHP_INT_MAX is 2^63 on 64-bit builds (above every int) and exact on 32-bit + // ones; so is PHP_INT_MAX - 0.5, which is PHP_INT_MAX - 1 + 0.5 on 32-bit builds + $maxIsExact = PHP_INT_SIZE < 8; + yield [$smallerThan, (float) PHP_INT_MAX, $maxIsExact ? 'int' : 'int']; + yield [$smallerThanOrEqualTo, (float) PHP_INT_MAX, 'int']; + yield [$greaterThan, (float) PHP_INT_MAX, '*NEVER*']; + yield [$greaterThanOrEqualTo, (float) PHP_INT_MAX, $maxIsExact ? (string) PHP_INT_MAX : '*NEVER*']; + yield [$smallerThan, PHP_INT_MAX - 0.5, $maxIsExact ? 'int' : 'int']; + yield [$greaterThanOrEqualTo, PHP_INT_MAX - 0.5, $maxIsExact ? (string) PHP_INT_MAX : '*NEVER*']; + + // between PHP_INT_MAX and the next int on 32-bit builds, 2^63 on 64-bit ones: its + // ceil() is past the int range either way + yield [$smallerThan, PHP_INT_MAX + 0.5, 'int']; + yield [$greaterThanOrEqualTo, PHP_INT_MAX + 0.5, '*NEVER*']; + + yield [$smallerThan, 2.5, 'int']; + yield [$greaterThan, 2.5, 'int<3, max>']; + } + + /** + * @param callable(float): Type $factory + */ + #[DataProvider('dataFloatBounds')] + public function testFloatBounds(callable $factory, float $value, string $expected): void + { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new RuntimeException($errstr); + }); + try { + $this->assertSame($expected, $factory($value)->describe(VerbosityLevel::precise())); + } finally { + restore_error_handler(); + } + } + }