Skip to content

Narrow division to int when modulo is known to be zero #5757

Merged
staabm merged 4 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-uzkbeoe
May 26, 2026
Merged

Narrow division to int when modulo is known to be zero #5757
staabm merged 4 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-uzkbeoe

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be instead possible to say that

0 === ($offset % $limit) imply $offset / $limit is an int ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what changed:

Approach change: Instead of making all integer range divisions return benevolent unions (the previous commit's approach), the fix now detects when $a % $b is known to be 0 in the current scope and narrows $a / $b to just int by removing float from the result type.

How it works: In getDivType(), after computing the normal division result, a synthetic BinaryOp\Mod expression is constructed with the same operands and queried through the scope's type resolver. When a condition like 0 === ($a % $b) has narrowed the modulo type to ConstantIntegerType(0), TypeCombinator::remove() strips float from the division result.

Changes:

  • InitializerExprTypeResolver::getDivType() — added modulo-based narrowing
  • Reverted all BenevolentUnionType propagation changes from the previous commit (resolveCommonMath, integerRangeMath)
  • bug-9724.php test — assertions now expect precise int types instead of benevolent (float|int) unions
  • Reverted test expectation changes in div-by-zero.php, integer-range-types.php, math.php

@VincentLanglet VincentLanglet changed the title Preserve BenevolentUnionType for IntegerRangeType division results in integerRangeMath Narrow division to int when modulo is known to be zero May 25, 2026
Comment thread src/Reflection/InitializerExprTypeResolver.php
@VincentLanglet VincentLanglet self-assigned this May 25, 2026
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-uzkbeoe branch from f5391b1 to 0a655aa Compare May 26, 2026 12:12
@VincentLanglet VincentLanglet requested a review from staabm May 26, 2026 12:13

if ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) {
$modType = $getTypeCallback(new BinaryOp\Mod($left, $right));
if ($modType instanceof ConstantIntegerType && $modType->getValue() === 0) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ($modType instanceof ConstantIntegerType && $modType->getValue() === 0) {
if ($modType->isSuperTypeOf(new ConstantIntegerType(0))->yes()) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All tests pass and static analysis is clean. The remote commit already addressed staabm's review:

  • Replaced $modType instanceof ConstantIntegerType && $modType->getValue() === 0 with $modType->isInteger()->yes() && (new ConstantIntegerType(0))->isSuperTypeOf($modType)->yes()
  • Note: the isSuperTypeOf direction is reversed from the reviewer's suggestion (ConstantIntegerType(0)->isSuperTypeOf($modType) not $modType->isSuperTypeOf(ConstantIntegerType(0))) because we need "is modType a subtype of 0" (i.e., exactly 0), not "is modType a supertype of 0" (which would match int, mixed, etc.)
  • The $modType->isInteger()->yes() guard prevents false positives from NeverType (the bottom type satisfies any isSuperTypeOf check, and the mod range computation can produce NeverType for certain negative divisors)

No additional changes needed — the branch is already up to date.

Comment thread src/Reflection/InitializerExprTypeResolver.php Outdated
VincentLanglet and others added 2 commits May 26, 2026 20:10
…s in `integerRangeMath`

- Return benevolent union from `integerRangeMath` when division of two integer
  types produces `int<range>|float`, matching the existing behavior for plain
  `int / int` division
- Propagate benevolence through union-combining code in both `integerRangeMath`
  (operand union handling) and `resolveCommonMath` (left number type union
  handling) by tracking whether any part returned a `BenevolentUnionType`
- Simplify the cross-zero range-splitting blocks to use
  `TypeUtils::toBenevolentUnion()` instead of an equality check against
  `UnionType([int, float])`, which missed bounded integer range results
- Update test expectations in integer-range-types.php, div-by-zero.php,
  math.php, and binary.php to reflect the corrected benevolent union types
Instead of making all integer range divisions return benevolent unions,
detect when the scope knows that `$a % $b === 0` and narrow `$a / $b`
to just the integer type (removing float from the union).

This is more precise: it only removes float when mathematically
guaranteed, rather than broadly making all integer range divisions
benevolent. The check works by constructing a synthetic modulo
expression with the same operands and querying the scope for its type.
When a condition like `0 === ($a % $b)` has narrowed the modulo to
ConstantIntegerType(0), the division result is guaranteed to be int.

Reverts the BenevolentUnionType propagation changes from the previous
commit since the modulo narrowing is the correct fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-uzkbeoe branch from 4e2210f to 9024135 Compare May 26, 2026 18:11
@VincentLanglet
Copy link
Copy Markdown
Contributor

@staabm I feel like we still need getDivTypeFromTypes here because we want to remove Float either for constantScalar if or for the general condition (and there was multiple early return). Do you see a better way ?

@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 26, 2026

As long as it is private, I am fine

Comment thread src/Reflection/InitializerExprTypeResolver.php
@VincentLanglet VincentLanglet requested a review from staabm May 26, 2026 18:46
Comment on lines +36 to +45
private function expectInt(int $page): void
{
}

public function originalIssue(?int $limit, int $offset = 0): void
{
if ($limit && $offset && (0 === ($offset % $limit))) {
$this->expectInt(($offset / $limit) + 1);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code needs to be analyzed by a RuleTest to proof it fixes the error reported in phpstan/phpstan#9724

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added a CallMethodsRule test (testBug9724) with a test data file that reproduces the exact scenario from issue #9724 — calling $this->expectInt(($offset / $limit) + 1) inside a modulo-checked condition. The test expects zero errors, proving the fix resolves the originally reported type mismatch. All 12156 tests pass and make phpstan reports no errors.

phpstan-bot and others added 2 commits May 26, 2026 19:19
@staabm staabm merged commit 723081d into phpstan:2.1.x May 26, 2026
653 of 660 checks passed
@staabm staabm deleted the create-pull-request/patch-uzkbeoe branch May 26, 2026 19:34
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.

3 participants