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
Open
Derive the intdiv() result range from the operand bounds and narrow <</>> by a range shift count#6494phpstan-bot wants to merge 1 commit into
intdiv() result range from the operand bounds and narrow <</>> by a range shift count#6494phpstan-bot wants to merge 1 commit into
Conversation
… `<<`/`>>` 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>
Contributor
|
fells like stuff for multiple PRs. can we separate in more PRs? |
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
intdiv()returned plainintno matter what was known about its arguments, sointdiv($nonNegativeInt, $positiveInt)could not be used where anon-negative-intwas expected — and even
intdiv(10, 3)was not folded to3. This PR derives theresult range of
intdiv()from the ranges of its operands, and applies the samecorner-based interval math to the sibling constructs that had the same gap.
Changes
src/Type/Php/IntdivFunctionReturnTypeExtension.php(new): computes theintdiv()result range from the operand ranges. The divisor range is split intoits 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_LIMITpairs, sointdiv(1|2|4, 2)is0|1|2; beyond the limit both sides collapse to a single hull.src/Type/Php/FloatDivisionFunctionsReturnTypeExtension.php(new): constant-foldsfdiv()andfmod(), the function forms of/and%for floats. Both returnedplain
floateven for constant arguments, while10 / 4folds to2.5. INF andNAN results fall back to the signature type.
src/Reflection/InitializerExprTypeResolver.php:integerRangeMath()now narrows<<and>>when the shift count is an integerrange and not only a constant. New
shiftLeftRange(),shiftRightRange()andinvalidShiftCount()helpers replace the two inline branches, andshiftRight()models a shift by an unbounded count.
<<range for operands with an unbounded side (see below).tests/PHPStan/Analyser/nsrt/integer-range-types.php: two assertions thatrecorded the unsound
<<ranges were corrected,$a >> $bis now narrowed, and anew
shiftByRange()method covers shifting by a range.Probed and found already correct, so no change was needed:
%viagetModType(),/viagetDivType()/integerRangeMath(),pow(),abs(),min()/max(), andnamed-argument calls (
ArgumentsNormalizerreorders arguments before dynamic returntype 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:
intdiv()had noDynamicFunctionReturnTypeExtensionat all, so it always fellback to its
intsignature. It cannot simply delegate to/:getDivType()rounds the float bounds with
ceil()/floor(), whileintdiv()truncatestowards zero.
int<5, 6> / 2has the boundceil(2.5) == 3, butintdiv(5, 2) == 2. Truncation is monotonic, so the correct bounds come fromapplying
intdiv()itself at the corners of the operand ranges.fdiv()/fmod()likewise had no extension, while their operator counterpartsfold constants.
integerRangeMath()handled<</>>only for aConstantIntegerTypeshiftcount and bailed out to
intfor anything else, even though both operators aremonotonic in the shift count too.
The
<<unsoundness is a separate defect in the same block: the overflow guard onlyran for bounds that were not
null, so an unbounded side was shifted "in spirit"without ever being checked.
int<5, max> << 1claimedint<10, max>, but(PHP_INT_MAX - 1) << 1wraps around to-4;int<min, -5> << 1claimedint<min, -10>, butPHP_INT_MIN << 1is0. The guard now substitutesPHP_INT_MIN/PHP_INT_MAXfor an unbounded side, which always overflows for anon-zero shift, so those cases widen to
int.Test
tests/PHPStan/Analyser/nsrt/bug-15282.php— the reproducer from the issue'splayground link, asserting
int<0, max>forintdiv($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
0divisor,PHP_INT_MIN / -1).tests/PHPStan/Analyser/nsrt/fdiv-fmod.php— constant folding offdiv()/fmod()including unions and negative operands, plus the INF/NAN fallbacks.
tests/PHPStan/Analyser/nsrt/integer-range-types.php— newshiftByRange()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 PHPover 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