Skip to content

Compare float bounds against PHP_INT_MAX + 1.0 in IntegerRangeType - #6546

Open
zonuexe wants to merge 2 commits into
phpstan:2.3.xfrom
zonuexe:int-range-float-bound-32bit
Open

zonuexe wants to merge 2 commits into
phpstan:2.3.xfrom
zonuexe:int-range-float-bound-32bit

Conversation

@zonuexe

@zonuexe zonuexe commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

#6541 (53cc365) changed IntegerRangeType::createAllSmallerThan() and createAllGreaterThanOrEqualTo() to treat a float bound >= PHP_INT_MAX as past the int range. On 64-bit builds that is right, because comparing with PHP_INT_MAX converts it to float and rounds it up to 2^63. On 32-bit builds PHP_INT_MAX (2147483647) is exact as a float, so:

  • createAllGreaterThanOrEqualTo(2147483647.0) returns *NEVER*, although the int PHP_INT_MAX satisfies $i >= 2147483647.0. This is unsound.
  • createAllSmallerThan(2147483647.0) returns int instead of int<min, 2147483646>. This is sound but imprecise.

This PR decides on the ceil() that is about to be cast, and compares it with PHP_INT_MAX + 1.0. That is the first float above PHP_INT_MAX and is exact on both widths (2^63 and 2^31). Using the ceil() rather than $value matters on 32-bit, where for example 2147483647.5 lies between PHP_INT_MAX and PHP_INT_MAX + 1.0. The turbo mirror in turbo-ext/src/IntegerRangeType.cpp gets the same change, followed by the usual bump commit.

64-bit behaviour is unchanged for int|float input. On 64-bit, every float at or above 2^52 is already integral, so ceil($value) >= 2^63 is the same test as $value >= (float) PHP_INT_MAX. The only observable difference is for out-of-contract arguments (a string or bool passed to @param int|float $value). createAllSmallerThan() now reaches ceil() first and throws its TypeError for these, even where the old loose comparison returned a type. The native side does the same, and type-family.php records it.

IntegerRangeTypeTest replaces the 64-bit-only expectations from 53cc365 with a data provider that branches on PHP_INT_SIZE. On 64-bit hosts it passes both before and after this change, so on 64-bit CI it only guards against regressions. I ran it on linux/386 (Docker php:8.5-cli, PHP 8.5.10, PHP_INT_SIZE 4). Before the change, two rows fail there: createAllGreaterThanOrEqualTo(2147483647.0) gives *NEVER* instead of 2147483647, and createAllSmallerThan(2147483647.0) gives int instead of int<min, 2147483646>. After the change all 18 rows pass.

Whether PHPStan supports running on a 32-bit PHP at all isn't settled (phpstan/phpstan#11711, phpstan/phpstan#14948), and composer.json has no php-64bit requirement. This PR only makes these two factories correct on such a host and keeps 64-bit behaviour as it is. If 32-bit analyser hosts should be unsupported instead, feel free to close this.

#6542 applies the same >= guard to 2.2.x; I'll update it to this approach so the two branches agree when 2.2.x is merged up.

Verified on macOS arm64, PHP 8.5.10:

  • Strict turbo build, smoke.php (ALL OK), side-by-side.php, signature-parity.php, walk-trace.php --shards=8 (identical)
  • Full test suite with and without the extension
  • Self-analysis output byte-identical with and without the extension

zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Sep 23, 2026
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<min, 2147483646>.

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#6546.

testCreateFromFloat's literals are 64-bit int bounds, so it is skipped on
32-bit hosts; testFloatBounds covers both widths.
createAllSmallerThan() and createAllGreaterThanOrEqualTo() treated a float
bound reaching 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) returned never although the
int PHP_INT_MAX satisfies it, and createAllSmallerThan(2147483647.0)
returned int instead of int<min, 2147483646>.

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 is unchanged there.
@zonuexe
zonuexe force-pushed the int-range-float-bound-32bit branch from d908441 to 6031e92 Compare September 25, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant