Skip to content

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

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

% with a union-typed integer divisor inferred a wrong result range: off by one for plain
range 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 false always false comparison errors.

The three branches of InitializerExprTypeResolver::getModType() (single range, single
constant, union) are replaced by one computation over the divisor's [min, max] bounds,
which also fixes the negative/PHP_INT_MIN divisor cases reported in
phpstan/phpstan#15242.

Changes

src/Reflection/InitializerExprTypeResolver.php:

  • getModType() now computes the result range from (a) the largest magnitude the divisor
    can have and (b) the dividend's sign and bounds, instead of branching on the shape of the
    divisor type.
  • New getIntegerBounds(Type): array{int|null, int|null}|null collapses an
    IntegerRangeType, a ConstantIntegerType or a UnionType of those into a single
    [min, max] pair, with null for an unbounded side and null for anything else.
  • New getMaxModuloMagnitude(?int, ?int) turns those bounds into max(|min|, |max|) - 1,
    returning null (unbounded) when either side is unbounded or the lower bound is
    PHP_INT_MIN.

Cases fixed, all through the same computation:

  • Off-by-one on range unions: int<0, max> % (int<1, 5>|int<10, 20>) → int<0, 19> (was
    int<0, 20>).
  • Unbounded union member dropped: int<0, max> % (int<30, max>|7) → int<0, max> (was
    int<0, 6>).
  • Negative union members: int<0, max> % (int<-20, -10>|int<10, 15>) → int<0, 19> (was
    int<0, 15>); int<0, max> % (int<-20, -10>|int<-5, -3>) → int<0, 19> (was *NEVER*).
  • Analogous, non-union divisors (Modulo by a negative integer divisor is inferred as *NEVER* (and % PHP_INT_MIN crashes 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).
  • Analogous, PHP_INT_MIN divisor: int<0, 10> % PHP_INT_MIN → int<0, 10> instead of
    PHP_INT_MIN - 1 overflowing to a float and producing *NEVER*.
  • Analogous, unbounded lower end of a single range: int<0, max> % int<min, 20> →
    int<0, max> (was int<0, 19>, too narrow — the same "unbounded member ignored" bug
    outside a union).
  • Analogous, dividend sign: the result of % carries the sign of the dividend, so a
    non-positive dividend now gives a non-positive result:
    int<min, 0> % (int<1, 5>|int<10, 20>) → int<-19, 0> (was int<-19, 19>), and
    int<min, 0> % $int → int<min, 0> (was int).
  • Analogous, dividend magnitude: |$a % $b| <= |$a|, so the dividend's own bounds cap
    the result: int<0, 10> % int<5, 20> → int<0, 10> (was int<0, 19>). This also applies
    when the divisor is not an integer type, e.g. int<0, 10> % $float → int<0, 10>.

Probed and already correct, so left alone: %= (AssignOpHandler routes to the same
getModType(), covered by a test), / (getDivType()/integerRangeMath() never produces
a modulo-style range), and intdiv()/fmod() (no range-computing extensions).

tests/PHPStan/Analyser/nsrt/modulo-operator.php and
tests/PHPStan/Analyser/nsrt/integer-range-types.php had two expectations that encoded the
buggy behaviour:

  • int % (int<min, 3>|7) and positive-int % (int<min, 3>|7) expected int<-6, 6> /
    int<0, 6> — exactly the "unbounded member overwritten by a later constant" bug. The
    divisor can be arbitrarily negative, so the correct types are int and int<0, max>.
  • int<min, 5> / -1 expected float|int<-5, max>. getDivType() drops the float member
    when the matching % is always 0; int<min, 5> % -1 used to be *NEVER* and is now
    0, so division by -1 is recognised as exact, matching what / 1 already did.

Root cause

getModType() had one code path per shape of the divisor type, and each derived the result
bound differently:

  1. The union branch used $type->getMax() for IntegerRangeType members but
    $type->getValue() - 1 for ConstantIntegerType members, so range members were off by
    one against the single-range branch.
  2. The union branch used null both for "no member seen yet" and for "unbounded". An
    unbounded member set $rangeMax = null, and a later member overwrote it because
    max(null, 6) is 6. TypeCombinator::union() orders integer ranges before constant
    scalars, so int<30, max>|7 reliably lost its unbounded member. The same
    max(null, 0) === null quirk threw away a real bound of 0.
  3. Every branch derived the bound from the divisor's signed maximum. In PHP the sign of
    $a % $b follows the dividend and the divisor only caps the magnitude at |$b| - 1, so
    a negative divisor produced a negative $rangeMax and IntegerRangeType::fromInterval(0, negative) collapsed to *NEVER*. PHP_INT_MIN - 1 additionally 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 two
operands' [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 verbatim
    as 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 the
    bar() function is gone.
  • tests/PHPStan/Analyser/nsrt/bug-15242.php — the sibling issue's table of expected types
    for negative constant and range divisors, plus the PHP_INT_MIN divisor that used to
    produce an internal error.

make tests, make phpstan and make cs-fix are green. make name-collision fails on
tests/PHPStan/Rules/Methods/data/static-call-pipe.php both with and without this change.

Fixes phpstan/phpstan#15243

…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*`.
@ondrejmirtes
ondrejmirtes merged commit 60f36f0 into phpstan:2.2.x Sep 17, 2026
857 of 890 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-9ljf2vy branch September 17, 2026 09:32
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