Skip to content

Derive the intdiv() result range from the operand bounds and narrow <</>> by a range shift count - #6494

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-yj4qjlh
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-yj4qjlh

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

intdiv() returned plain int no matter what was known about its arguments, so
intdiv($nonNegativeInt, $positiveInt) could not be used where a non-negative-int
was expected — and even intdiv(10, 3) was not folded to 3. This PR derives the
result range of intdiv() from the ranges of its operands, and applies the same
corner-based interval math to the sibling constructs that had the same gap.

Changes

  • src/Type/Php/IntdivFunctionReturnTypeExtension.php (new): computes the
    intdiv() result range from the operand ranges. The divisor range is split into
    its negative and its positive part (zero is dropped — dividing by it throws), and
    each range pair is evaluated at its four corners. Union members are handled
    individually up to RANGE_COMBINATIONS_LIMIT pairs, so intdiv(1|2|4, 2) is
    0|1|2; beyond the limit both sides collapse to a single hull.
  • src/Type/Php/FloatDivisionFunctionsReturnTypeExtension.php (new): constant-folds
    fdiv() and fmod(), the function forms of / and % for floats. Both returned
    plain float even for constant arguments, while 10 / 4 folds to 2.5. INF and
    NAN results fall back to the signature type.
  • src/Reflection/InitializerExprTypeResolver.php:
    • integerRangeMath() now narrows << and >> when the shift count is an integer
      range and not only a constant. New shiftLeftRange(), shiftRightRange() and
      invalidShiftCount() helpers replace the two inline branches, and shiftRight()
      models a shift by an unbounded count.
    • Fixes an unsound << range for operands with an unbounded side (see below).
  • tests/PHPStan/Analyser/nsrt/integer-range-types.php: two assertions that
    recorded the unsound << ranges were corrected, $a >> $b is now narrowed, and a
    new shiftByRange() method covers shifting by a range.

Probed and found already correct, so no change was needed: % via getModType(),
/ via getDivType()/integerRangeMath(), pow(), abs(), min()/max(), and
named-argument calls (ArgumentsNormalizer reorders arguments before dynamic return
type extensions see them).

Root cause

The pattern is integer-range information being discarded by operations that are
perfectly monotonic in their operands
. Three places were affected:

  1. intdiv() had no DynamicFunctionReturnTypeExtension at all, so it always fell
    back to its int signature. It cannot simply delegate to /: getDivType()
    rounds the float bounds with ceil()/floor(), while intdiv() truncates
    towards zero. int<5, 6> / 2 has the bound ceil(2.5) == 3, but
    intdiv(5, 2) == 2. Truncation is monotonic, so the correct bounds come from
    applying intdiv() itself at the corners of the operand ranges.
  2. fdiv()/fmod() likewise had no extension, while their operator counterparts
    fold constants.
  3. integerRangeMath() handled <</>> only for a ConstantIntegerType shift
    count and bailed out to int for anything else, even though both operators are
    monotonic in the shift count too.

The << unsoundness is a separate defect in the same block: the overflow guard only
ran for bounds that were not null, so an unbounded side was shifted "in spirit"
without ever being checked. int<5, max> << 1 claimed int<10, max>, but
(PHP_INT_MAX - 1) << 1 wraps around to -4; int<min, -5> << 1 claimed
int<min, -10>, but PHP_INT_MIN << 1 is 0. The guard now substitutes
PHP_INT_MIN/PHP_INT_MAX for an unbounded side, which always overflows for a
non-zero shift, so those cases widen to int.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15282.php — the reproducer from the issue's
    playground link, asserting int<0, max> for intdiv($zeroOrMore, $oneOrMore).
  • tests/PHPStan/Analyser/nsrt/intdiv.php — sign combinations (non-negative /
    non-positive / positive / negative dividends and divisors), bounded ranges,
    unions, constant folding, and the cases that stay int (unbounded operand,
    constant 0 divisor, PHP_INT_MIN / -1).
  • tests/PHPStan/Analyser/nsrt/fdiv-fmod.php — constant folding of fdiv()/fmod()
    including unions and negative operands, plus the INF/NAN fallbacks.
  • tests/PHPStan/Analyser/nsrt/integer-range-types.php — new shiftByRange()
    method, plus the corrected << assertions and the narrowed $a >> $b.

All four files were verified to fail before the source changes. Beyond the unit
tests, the intdiv(), << and >> range math was brute-forced against real PHP
over 756 and 560 range-pair combinations respectively: every reported range contains
every value the operation can actually produce, and every reported bound is
attainable.

Fixes phpstan/phpstan#15282

🤖 Generated with Claude Code

… `<<`/`>>` by a range shift count

* New `IntdivFunctionReturnTypeExtension` computes the result range of `intdiv()`.
  Truncated division is monotonic in both operands as long as the divisor does not
  change sign, so the extremes of the result are always found at the corners of the
  operand ranges. The divisor range is split into its negative and its positive part
  (zero is dropped, dividing by it throws), each pair of ranges is evaluated at its
  four corners and the results are unioned. Unbounded sides are carried as -INF/INF;
  a corner where both operands are unbounded is indeterminate and skipped, which is
  safe because the remaining corners always bracket it. `PHP_INT_MIN / -1` overflows
  and throws, so that corner is treated as unbounded. Union members are kept apart
  up to `RANGE_COMBINATIONS_LIMIT` pairs and collapsed to a single hull beyond it.
  `intdiv()` previously returned plain `int` for every input, including constants.
* Analogous case, same family of "function form of a division operator with no
  return type logic": new `FloatDivisionFunctionsReturnTypeExtension` constant-folds
  `fdiv()` and `fmod()`, which both returned plain `float` even for constant
  arguments while `/` and `%` fold. Results that are INF or NAN fall back to the
  signature type, since no constant float type can represent them.
* Analogous case in `InitializerExprTypeResolver::integerRangeMath()`: `<<` and `>>`
  gave up and returned `int` unless the shift count was a constant. Both are
  monotonic in both operands, so the bounds now come from the lowest value shifted
  the most and the highest value shifted the least, with "the most" following the
  sign of the shifted value. `int<8, 16> >> int<1, 2>` is `int<2, 8>` instead of
  `int`. Shifting right by an unbounded count leaves `0` for a non-negative value
  and `-1` for a negative one, which is what PHP produces for any count at least as
  large as the integer size.
* Bug fixed on the way: `<<` produced an unsound range for an operand with an
  unbounded side. `int<5, max> << 1` was `int<10, max>`, but `(PHP_INT_MAX - 1) << 1`
  wraps around to a negative number; `int<min, -5> << 1` was `int<min, -10>` while
  `PHP_INT_MIN << 1` is `0`. The overflow check now substitutes `PHP_INT_MIN` /
  `PHP_INT_MAX` for an unbounded side, so those cases correctly widen to `int`.
  The two assertions in `integer-range-types.php` that recorded the unsound ranges
  were updated.
* The three shift branches were extracted into `shiftLeftRange()`,
  `shiftRightRange()` and a shared `invalidShiftCount()` helper.
* Probed and found already correct: `%` via `getModType()` (brute-forced over 756
  range pairs: sound everywhere, only over-approximating for a constant dividend
  where the result has gaps), `/` via `getDivType()`/`integerRangeMath()`, `pow()`,
  `abs()`, `min()`/`max()`, and named-argument calls, which `ArgumentsNormalizer`
  already reorders before dynamic return type extensions see them.

Closes phpstan/phpstan#15282

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm

staabm commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

fells like stuff for multiple PRs. can we separate in more PRs?

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.

Narrow intdiv() return type when both arguments are non-negative

3 participants