Repository navigation
Derive the % result range from the divisor's magnitude and the dividend's sign and bounds - #6458
Merged
ondrejmirtes merged 1 commit intoSep 17, 2026
Conversation
…dend's sign and bounds * `InitializerExprTypeResolver::getModType()` no longer has three divergent branches (single range / constant / union). A new `getIntegerBounds()` helper collapses an integer range, an integer constant or a union of those into one `[min, max]` pair, and `getMaxModuloMagnitude()` turns that pair into `max(|min|, |max|) - 1`. * The union branch used `$type->getMax()` for range members but `value - 1` for constant members, so `int<1, 5>|int<10, 20>` produced an off-by-one `int<0, 20>`. * The union branch reused `null` for both "no member seen yet" and "unbounded", so an unbounded member was overwritten by a later one (`int<30, max>|7` produced `int<0, 6>`), and `max(null, 0)` silently dropped a real bound of 0. * The bound is now taken from the divisor's magnitude instead of its signed value, so a negative divisor no longer yields `*NEVER*` (`int<-20, -10>|int<-5, -3>`, and the non-union cases of phpstan/phpstan#15242: `$x % -9`, `$x % int<-10, -5>`, `$x % int<-20, 5>`). * `PHP_INT_MIN` as the divisor (or as the lower end of a range divisor) is treated as unbounded instead of overflowing `abs()`/`- 1` into a float. * Analogous cases fixed with the same computation: a non-positive dividend now gives a non-positive result (`int<min, 0> % int<10, 20>` is `int<-19, 0>`, not `int<-19, 19>`), a range divisor with an unbounded lower end is no longer treated as bounded (`$x % int<min, 20>` is `int<0, max>`, not `int<0, 19>`), and the result is additionally bounded by the dividend (`int<0, 10> % int<5, 20>` is `int<0, 10>`), which also applies when the divisor is not an integer type. * Probed and found already correct: `%=` (shares `getModType()`), `/` via `getDivType()`/`integerRangeMath()`, and `intdiv()`/`fmod()`, which have no range logic. `$rMin / -1` in integer-range-types.php now drops the `float` member because `int<min, 5> % -1` correctly resolves to `0` instead of `*NEVER*`.
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
%with a union-typed integer divisor inferred a wrong result range: off by one for plainrange unions, an unbounded member silently dropped, and negative members compared by their
signed value instead of their magnitude — the last one collapsing an all-negative divisor
union to
*NEVER*and causing falsealways falsecomparison errors.The three branches of
InitializerExprTypeResolver::getModType()(single range, singleconstant, union) are replaced by one computation over the divisor's
[min, max]bounds,which also fixes the negative/
PHP_INT_MINdivisor cases reported inphpstan/phpstan#15242.
Changes
src/Reflection/InitializerExprTypeResolver.php:getModType()now computes the result range from (a) the largest magnitude the divisorcan have and (b) the dividend's sign and bounds, instead of branching on the shape of the
divisor type.
getIntegerBounds(Type): array{int|null, int|null}|nullcollapses anIntegerRangeType, aConstantIntegerTypeor aUnionTypeof those into a single[min, max]pair, withnullfor an unbounded side andnullfor anything else.getMaxModuloMagnitude(?int, ?int)turns those bounds intomax(|min|, |max|) - 1,returning
null(unbounded) when either side is unbounded or the lower bound isPHP_INT_MIN.Cases fixed, all through the same computation:
int<0, max> % (int<1, 5>|int<10, 20>)→int<0, 19>(wasint<0, 20>).int<0, max> % (int<30, max>|7)→int<0, max>(wasint<0, 6>).int<0, max> % (int<-20, -10>|int<10, 15>)→int<0, 19>(wasint<0, 15>);int<0, max> % (int<-20, -10>|int<-5, -3>)→int<0, 19>(was*NEVER*).*NEVER*(and% PHP_INT_MINcrashes with an internal error) phpstan#15242):$nonNegative % -9→int<0, 8>,$any % -9→int<-8, 8>,$nonNegative % int<-10, -5>→int<0, 9>,$nonNegative % int<-20, 5>→int<0, 19>(all previously*NEVER*or too narrow).PHP_INT_MINdivisor:int<0, 10> % PHP_INT_MIN→int<0, 10>instead ofPHP_INT_MIN - 1overflowing to a float and producing*NEVER*.int<0, max> % int<min, 20>→int<0, max>(wasint<0, 19>, too narrow — the same "unbounded member ignored" bugoutside a union).
%carries the sign of the dividend, so anon-positive dividend now gives a non-positive result:
int<min, 0> % (int<1, 5>|int<10, 20>)→int<-19, 0>(wasint<-19, 19>), andint<min, 0> % $int→int<min, 0>(wasint).|$a % $b| <= |$a|, so the dividend's own bounds capthe result:
int<0, 10> % int<5, 20>→int<0, 10>(wasint<0, 19>). This also applieswhen the divisor is not an integer type, e.g.
int<0, 10> % $float→int<0, 10>.Probed and already correct, so left alone:
%=(AssignOpHandlerroutes to the samegetModType(), covered by a test),/(getDivType()/integerRangeMath()never producesa modulo-style range), and
intdiv()/fmod()(no range-computing extensions).tests/PHPStan/Analyser/nsrt/modulo-operator.phpandtests/PHPStan/Analyser/nsrt/integer-range-types.phphad two expectations that encoded thebuggy behaviour:
int % (int<min, 3>|7)andpositive-int % (int<min, 3>|7)expectedint<-6, 6>/int<0, 6>— exactly the "unbounded member overwritten by a later constant" bug. Thedivisor can be arbitrarily negative, so the correct types are
intandint<0, max>.int<min, 5> / -1expectedfloat|int<-5, max>.getDivType()drops thefloatmemberwhen the matching
%is always0;int<min, 5> % -1used to be*NEVER*and is now0, so division by-1is recognised as exact, matching what/ 1already did.Root cause
getModType()had one code path per shape of the divisor type, and each derived the resultbound differently:
$type->getMax()forIntegerRangeTypemembers but$type->getValue() - 1forConstantIntegerTypemembers, so range members were off byone against the single-range branch.
nullboth for "no member seen yet" and for "unbounded". Anunbounded member set
$rangeMax = null, and a later member overwrote it becausemax(null, 6)is6.TypeCombinator::union()orders integer ranges before constantscalars, so
int<30, max>|7reliably lost its unbounded member. The samemax(null, 0) === nullquirk threw away a real bound of0.$a % $bfollows the dividend and the divisor only caps the magnitude at|$b| - 1, soa negative divisor produced a negative
$rangeMaxandIntegerRangeType::fromInterval(0, negative)collapsed to*NEVER*.PHP_INT_MIN - 1additionally overflowed to a float.The fix names the actual rule once — the result keeps the dividend's sign and is bounded by
both the dividend's magnitude and
|divisor| - 1— and derives every case from the twooperands'
[min, max]bounds, so range, constant and union divisors can no longer disagree.Test
tests/PHPStan/Analyser/nsrt/bug-15243.php— the reporter's playground snippet verbatimas
assertType()calls, plus the analogous non-positive-dividend, unbounded-lower-end and%=cases. Fails on every one of the reported lines without the fix.tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest::testBug15243— analyses the same file and expects no errors, locking in that the
Comparison operation ">" between int<0, 6> and 10 is always false.false positive on thebar()function is gone.tests/PHPStan/Analyser/nsrt/bug-15242.php— the sibling issue's table of expected typesfor negative constant and range divisors, plus the
PHP_INT_MINdivisor that used toproduce an internal error.
make tests,make phpstanandmake cs-fixare green.make name-collisionfails ontests/PHPStan/Rules/Methods/data/static-call-pipe.phpboth with and without this change.Fixes phpstan/phpstan#15243