Repository navigation
Derive the % result range from the divisor's magnitude and the dividend's sign - #6459
Merged
ondrejmirtes merged 1 commit intoSep 17, 2026
Merged
Conversation
ondrejmirtes
requested changes
Sep 17, 2026
ondrejmirtes
left a comment
Member
There was a problem hiding this comment.
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
force-pushed
the
create-pull-request/patch-wekqzhw
branch
from
September 17, 2026 15:37
7fcd1d3 to
06aca16
Compare
ondrejmirtes
approved these changes
Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
$a % $bwith a negative divisor was inferred as*NEVER*, and aPHP_INT_MINdivisor crashed the analysis with an internal error.In PHP the sign of
$a % $bfollows 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 % -9producedIntegerRangeType::fromInterval(0, -10)— an inverted interval, i.e.never— andPHP_INT_MIN - 1overflowed to a float, whichIntegerRangeType::fromInterval()rejects.The result range is now derived from the divisor's magnitude, and additionally clamped by the dividend, since
$a % $bkeeps the sign of$aand never exceeds its magnitude.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> % 9int<-8, 8>int<-8, 0>int<0, 10> % PHP_INT_MINint<0, 10>int<min, 3>|7as divisorint<-6, 6>intChanges
All in
src/Reflection/InitializerExprTypeResolver.php:getModType()— rewritten tail. The divisor bound comes from the newgetModMagnitudeLimit(); the dividend bound comes fromgetIntegerBounds(). Both operands go throughtoInteger(), matching what%does at runtime, sofloat % 2.4is nowint<-1, 1>rather thanint.getModMagnitudeLimit()(new) —max(|min|, |max|) - 1over the divisor's integer bounds,nullwhen either side is unbounded or when the lower bound isPHP_INT_MIN(whose absolute value does not fit into an integer).getIntegerBounds()(new) — collects[min, max]fromConstantIntegerType,IntegerRangeTypeandUnionTypein one place, tracking the two sides independently so a partially unbounded union keeps its known side. This replaces the three duplicatedinstanceofbranches that used to live inline ingetModType().integerRangeMath(),ShiftLeftbranch — bails out tointwhen either bound overflows the shift, detected by the newshiftLeftOverflows()round-trip helper.getDivType()— thefloatremoval that fires when the modulo is provably0is now skipped when it would leavenever.Test data updated where it asserted the old, incorrect behaviour:
tests/PHPStan/Analyser/nsrt/modulo-operator.php—int<min, 3>|7as a divisor isint/int<0, max>, notint<-6, 6>/int<0, 6>.tests/PHPStan/Analyser/nsrt/binary.php—$float3 %= 2.4isint<-1, 1>.tests/PHPStan/Analyser/nsrt/integer-range-types.php—int<min, 5> / -1no longer carries a spuriousfloat, becauseint<min, 5> % -1is now correctly0.Root cause
Two patterns, both in integer range math:
Signed bounds used where magnitude was meant.
%bounds the result by|divisor| - 1, but the code used the signedmax. For a negative divisor this yieldsmin > max, andIntegerRangeType::fromInterval()turns an inverted interval intonever— 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- 1for range members, and an unbounded member could be overwritten by a later bounded one.Arithmetic on range endpoints that does not fit into an integer.
$rightType->getValue() - 1onPHP_INT_MINsilently becomes a float and blows up the?intparameter. The same shape appears inintegerRangeMath()'sShiftLeft, whereintval($rangeMin) << $shiftwraps 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: onceint<min, 5> % -1correctly infers0, the float-removal shortcut could be applied to a float-only result and erase it entirely.Test
New
tests/PHPStan/Analyser/nsrt/bug-15242.phpcovers:PHP_INT_MINdivisor that used to crash, plusPHP_INT_MAXand-PHP_INT_MAXneighbours;int<min, 0> % ±9), the sign-preservation half of the fix;-9|-3) and half-unbounded negative ranges (int<min, -5>) as divisors;%=compound-assignment form;int<0, 30> / -1,int<min, 5> / -1,PHP_INT_MIN / -1);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