Skip to content

Derive the % result range from the divisor's magnitude and the dividend's sign - #6459

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-wekqzhw
Sep 17, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-wekqzhw

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

$a % $b with a negative divisor was inferred as *NEVER*, and a PHP_INT_MIN divisor crashed the analysis with an internal error.

In PHP the sign of $a % $b follows the dividend and the divisor only bounds the result's magnitude by |$b| - 1. getModType() instead derived the upper bound from the signed divisor ($rightType->getValue() - 1), so $i % -9 produced IntegerRangeType::fromInterval(0, -10) — an inverted interval, i.e. never — and PHP_INT_MIN - 1 overflowed to a float, which IntegerRangeType::fromInterval() rejects.

The result range is now derived from the divisor's magnitude, and additionally clamped by the dividend, since $a % $b keeps the sign of $a and never exceeds its magnitude.

Expression (dividend) Before After
int<0, 30> % -9 *NEVER* int<0, 8>
int<-30, 30> % -9 *NEVER* int<-8, 8>
int<0, 30> % int<-10, -5> *NEVER* int<0, 9>
int<0, 30> % int<-20, 5> int<0, 4> int<0, 19>
int<min, 0> % 9 int<-8, 8> int<-8, 0>
int<0, 10> % PHP_INT_MIN internal error int<0, 10>
int<min, 3>|7 as divisor int<-6, 6> int

Changes

All in src/Reflection/InitializerExprTypeResolver.php:

  • getModType() — rewritten tail. The divisor bound comes from the new getModMagnitudeLimit(); the dividend bound comes from getIntegerBounds(). Both operands go through toInteger(), matching what % does at runtime, so float % 2.4 is now int<-1, 1> rather than int.
  • getModMagnitudeLimit() (new) — max(|min|, |max|) - 1 over the divisor's integer bounds, null when either side is unbounded or when the lower bound is PHP_INT_MIN (whose absolute value does not fit into an integer).
  • getIntegerBounds() (new) — collects [min, max] from ConstantIntegerType, IntegerRangeType and UnionType in one place, tracking the two sides independently so a partially unbounded union keeps its known side. This replaces the three duplicated instanceof branches that used to live inline in getModType().
  • integerRangeMath(), ShiftLeft branch — bails out to int when either bound overflows the shift, detected by the new shiftLeftOverflows() round-trip helper.
  • getDivType() — the float removal that fires when the modulo is provably 0 is now skipped when it would leave never.

Test data updated where it asserted the old, incorrect behaviour:

  • tests/PHPStan/Analyser/nsrt/modulo-operator.php — int<min, 3>|7 as a divisor is int / int<0, max>, not int<-6, 6> / int<0, 6>.
  • tests/PHPStan/Analyser/nsrt/binary.php — $float3 %= 2.4 is int<-1, 1>.
  • tests/PHPStan/Analyser/nsrt/integer-range-types.php — int<min, 5> / -1 no longer carries a spurious float, because int<min, 5> % -1 is now correctly 0.

Root cause

Two patterns, both in integer range math:

  1. Signed bounds used where magnitude was meant. % bounds the result by |divisor| - 1, but the code used the signed max. For a negative divisor this yields min > max, and IntegerRangeType::fromInterval() turns an inverted interval into never — which is why the symptom was *NEVER* rather than a too-wide type. The union branch had the same defect plus two more: it dropped the - 1 for range members, and an unbounded member could be overwritten by a later bounded one.

  2. Arithmetic on range endpoints that does not fit into an integer. $rightType->getValue() - 1 on PHP_INT_MIN silently becomes a float and blows up the ?int parameter. The same shape appears in integerRangeMath()'s ShiftLeft, where intval($rangeMin) << $shift wraps around instead of overflowing to float, so the shifted endpoints no longer preserve the ordering of the input endpoints — producing either an inverted (never) range or, worse, a plausible-looking but unsound one.

The getDivType() fix is a knock-on of the first: once int<min, 5> % -1 correctly infers 0, the float-removal shortcut could be applied to a float-only result and erase it entirely.

Test

New tests/PHPStan/Analyser/nsrt/bug-15242.php covers:

  • every expression from the issue report, including the expected types from its table;
  • the PHP_INT_MIN divisor that used to crash, plus PHP_INT_MAX and -PHP_INT_MAX neighbours;
  • non-positive dividends (int<min, 0> % ±9), the sign-preservation half of the fix;
  • negative constant unions (-9|-3) and half-unbounded negative ranges (int<min, -5>) as divisors;
  • the %= compound-assignment form;
  • the division knock-ons (int<0, 30> / -1, int<min, 5> / -1, PHP_INT_MIN / -1);
  • the overflowing left shifts (int<1, 2> << 62, int<1, 3> << 63, int<0, 30> << 60).

Every one of those assertions was verified to fail against the unpatched src/ first — 17 wrong inferences, one of them the reported internal error.

Fixes phpstan/phpstan#15242

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflict after merging #6458

…and bound `%` on non-integer operands

- `integerRangeMath()` computed `<<` bounds with a wrapping shift, so an overflowing shift
  produced an inverted range (`int<1, 2> << 62` was `*NEVER*`) or an unsound one
  (`int<1, 3> << 63` claimed `-9223372036854775808`, but `2 << 63` is `0`). Overflow is now
  detected with a shift round-trip and falls back to `int`. `>>` is arithmetic and cannot
  overflow, so it is left alone.
- `getDivType()` removed `float` from the result whenever the modulo was `0`, which turned
  `(PHP_INT_MIN) / -1` into `*NEVER*` because that result is float-only. The removal is now
  skipped when it would leave `never`.
- `getModType()` reads both operands' bounds through `toInteger()`, matching what `%` does
  at runtime, so `%` on non-integer numeric operands is bounded too (`float % 2.4` is
  `int<-1, 1>`). A divisor that truncates to `0` (`$f % 0.5`) still yields an unbounded
  result instead of `*NEVER*`.

`bug-15242.php` also gains the remaining `%` cases probed for phpstan/phpstan#15242
(non-positive dividends, `PHP_INT_MAX` divisors, negative constant unions, `%=`), which
already pass and stay as regression coverage.

Probed and found already correct: `abs()` / `toAbsoluteNumber()` (already guards
`PHP_INT_MIN`), `IntdivThrowTypeExtension` (already models `PHP_INT_MIN / -1`),
`toBitwiseNotType()`, and the bitwise and/or/xor range helpers.
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-wekqzhw branch from 7fcd1d3 to 06aca16 Compare September 17, 2026 15:37
@ondrejmirtes
ondrejmirtes merged commit dc636ee into phpstan:2.2.x Sep 17, 2026
860 of 891 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-wekqzhw branch September 17, 2026 16:02
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.

2 participants