From 94c8eb843792dab8374e42321a0d2a8a0cd20406 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Tue, 26 May 2026 19:37:03 +0000 Subject: [PATCH 1/8] Compute integer range bounds for bitwise AND, OR, XOR, and NOT on `IntegerRangeType` - Add analytical bounds computation in `InitializerExprTypeResolver` for bitwise AND, OR, and XOR when operands are non-negative bounded integers, so large ranges (>128 values) that exceed the finite enumeration limit still produce tight `IntegerRangeType` results instead of plain `int` - For AND: result is `int<0, min(leftMax, rightMax)>` when both sides are non-negative; `int<0, knownMax>` when only one side has bounds and the other is an integer type - For OR and XOR: result is `int<0, allBitsMask(max(leftMax, rightMax))>` when both sides are non-negative bounded integers - Add `IntegerRangeType::toBitwiseNotType()` so `~int` correctly produces `int<~b, ~a>` instead of plain `int` - Extract helper methods: `getNonNegativeIntegerBounds()`, `computeBitwiseAndRange()`, `computeBitwiseOrXorRange()`, `allBitsMask()` --- .../InitializerExprTypeResolver.php | 97 ++++++++++++++++--- src/Type/IntegerRangeType.php | 9 ++ tests/PHPStan/Analyser/nsrt/bug-14654.php | 65 +++++++++++++ 3 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14654.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 0b2c5270491..bea1712c579 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1063,7 +1063,14 @@ public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCall $result = $this->getFiniteOrConstantScalarTypes($leftType, $rightType, static fn ($a, $b) => $a & $b); if ($result instanceof Type) { return $result; - } elseif ($result === self::IS_SCALAR_TYPE) { + } + + $bitwiseAndRange = $this->computeBitwiseAndRange($leftType, $rightType); + if ($bitwiseAndRange !== null) { + return $bitwiseAndRange; + } + + if ($result === self::IS_SCALAR_TYPE) { $leftType = $this->optimizeScalarType($leftType); $rightType = $this->optimizeScalarType($rightType); } @@ -1084,20 +1091,10 @@ public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCall return new ErrorType(); } - $leftNumberType = $leftType->toNumber(); - $rightNumberType = $rightType->toNumber(); - - if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { + if ($leftType->toNumber() instanceof ErrorType || $rightType->toNumber() instanceof ErrorType) { return new ErrorType(); } - if ($rightNumberType instanceof ConstantIntegerType && $rightNumberType->getValue() >= 0) { - return IntegerRangeType::fromInterval(0, $rightNumberType->getValue()); - } - if ($leftNumberType instanceof ConstantIntegerType && $leftNumberType->getValue() >= 0) { - return IntegerRangeType::fromInterval(0, $leftNumberType->getValue()); - } - return new IntegerType(); } @@ -1122,7 +1119,14 @@ public function getBitwiseOrType(Expr $left, Expr $right, callable $getTypeCallb $result = $this->getFiniteOrConstantScalarTypes($leftType, $rightType, static fn ($a, $b) => $a | $b); if ($result instanceof Type) { return $result; - } elseif ($result === self::IS_SCALAR_TYPE) { + } + + $bitwiseOrRange = $this->computeBitwiseOrXorRange($leftType, $rightType); + if ($bitwiseOrRange !== null) { + return $bitwiseOrRange; + } + + if ($result === self::IS_SCALAR_TYPE) { $leftType = $this->optimizeScalarType($leftType); $rightType = $this->optimizeScalarType($rightType); } @@ -1171,7 +1175,14 @@ public function getBitwiseXorType(Expr $left, Expr $right, callable $getTypeCall $result = $this->getFiniteOrConstantScalarTypes($leftType, $rightType, static fn ($a, $b) => $a ^ $b); if ($result instanceof Type) { return $result; - } elseif ($result === self::IS_SCALAR_TYPE) { + } + + $bitwiseXorRange = $this->computeBitwiseOrXorRange($leftType, $rightType); + if ($bitwiseXorRange !== null) { + return $bitwiseXorRange; + } + + if ($result === self::IS_SCALAR_TYPE) { $leftType = $this->optimizeScalarType($leftType); $rightType = $this->optimizeScalarType($rightType); } @@ -1985,6 +1996,64 @@ private function optimizeScalarType(Type $type): Type return new UnionType($types); } + /** + * @return array{int, int}|null [min, max] or null if bounds are not known + */ + private function getNonNegativeIntegerBounds(Type $type): ?array + { + if ($type instanceof IntegerRangeType) { + $min = $type->getMin(); + $max = $type->getMax(); + if ($min !== null && $min >= 0 && $max !== null) { + return [$min, $max]; + } + return null; + } + if ($type instanceof ConstantIntegerType && $type->getValue() >= 0) { + return [$type->getValue(), $type->getValue()]; + } + return null; + } + + private function computeBitwiseAndRange(Type $leftType, Type $rightType): ?Type + { + $leftBounds = $this->getNonNegativeIntegerBounds($leftType); + $rightBounds = $this->getNonNegativeIntegerBounds($rightType); + if ($leftBounds !== null && $rightBounds !== null) { + return IntegerRangeType::fromInterval(0, min($leftBounds[1], $rightBounds[1])); + } + if ($leftBounds !== null && $rightType->isInteger()->yes()) { + return IntegerRangeType::fromInterval(0, $leftBounds[1]); + } + if ($rightBounds !== null && $leftType->isInteger()->yes()) { + return IntegerRangeType::fromInterval(0, $rightBounds[1]); + } + return null; + } + + private function computeBitwiseOrXorRange(Type $leftType, Type $rightType): ?Type + { + $leftBounds = $this->getNonNegativeIntegerBounds($leftType); + $rightBounds = $this->getNonNegativeIntegerBounds($rightType); + if ($leftBounds === null || $rightBounds === null) { + return null; + } + $maxValue = max($leftBounds[1], $rightBounds[1]); + $upperBound = self::allBitsMask($maxValue); + return IntegerRangeType::fromInterval(0, $upperBound); + } + + private static function allBitsMask(int $value): int + { + $value |= $value >> 1; + $value |= $value >> 2; + $value |= $value >> 4; + $value |= $value >> 8; + $value |= $value >> 16; + $value |= $value >> 32; + return $value; + } + /** * @return TypeResult */ diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 605507008b3..9aa0a552832 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -511,6 +511,15 @@ public function toAbsoluteNumber(): Type return self::fromInterval(-$this->max, $inversedMin); } + public function toBitwiseNotType(): Type + { + // ~int = int<~b, ~a> (bitwise NOT reverses the order) + return self::fromInterval( + $this->max !== null ? ~$this->max : null, + $this->min !== null ? ~$this->min : null, + ); + } + public function toString(): Type { $finiteTypes = $this->getFiniteTypes(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14654.php b/tests/PHPStan/Analyser/nsrt/bug-14654.php new file mode 100644 index 00000000000..3e4251a6a20 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14654.php @@ -0,0 +1,65 @@ +', $a); + assertType('int<0, 255>', $b); + assertType('int<0, 255>', $a ^ $b); + assertType('int<0, 255>', $a | $b); + assertType('int<0, 255>', $a & $b); + + /** @var int<0, 255> $c */ + $c = 0; + assertType('int<0, 255>', $c ^ 42); + assertType('int<0, 255>', 42 ^ $c); + assertType('int<0, 255>', $c | 42); + assertType('int<0, 42>', $c & 42); + + /** @var int<0, 20> $x */ + $x = 0; + /** @var int<0, 20> $y */ + $y = 0; + assertType('int<0, 31>', $x ^ $y); + assertType('int<0, 31>', $x | $y); + assertType('int<0, 20>', $x & $y); + + // AND with ranges of different sizes + assertType('int<0, 20>', $a & $x); + assertType('int<0, 20>', $x & $a); + + // Unbounded ranges stay int + /** @var int<0, max> $unbounded */ + $unbounded = 0; + assertType('int', $unbounded ^ $a); + assertType('int', $unbounded | $a); + + // Negative ranges stay int for XOR/OR + /** @var int<-10, 10> $signed */ + $signed = 0; + assertType('int', $signed ^ $x); + assertType('int', $signed | $x); + + // Bitwise NOT preserves range bounds + assertType('int<-256, -1>', ~$a); + assertType('int<-21, -1>', ~$x); + + /** @var int $minBounded */ + $minBounded = 0; + assertType('int<-11, max>', ~$minBounded); + + /** @var int<-5, max> $maxBounded */ + $maxBounded = 0; + assertType('int', ~$maxBounded); + + // Compound assignment operators + /** @var int<0, 255> $d */ + $d = 0; + $d &= $a; + assertType('int<0, 255>', $d); +} From 63f896fe58e2b203164bb81c0e67853b64c37baf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 13 Aug 2026 09:16:22 +0000 Subject: [PATCH 2/8] Use typed parameters instead of inline @var in the bitwise range test Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-14654.php | 66 +++++++++++++++-------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14654.php b/tests/PHPStan/Analyser/nsrt/bug-14654.php index 3e4251a6a20..878c8f8134e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14654.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14654.php @@ -13,53 +13,73 @@ function foo(): void { assertType('int<0, 255>', $a ^ $b); assertType('int<0, 255>', $a | $b); assertType('int<0, 255>', $a & $b); +} - /** @var int<0, 255> $c */ - $c = 0; +/** + * @param int<0, 255> $c + */ +function withConstantOperand(int $c): void { assertType('int<0, 255>', $c ^ 42); assertType('int<0, 255>', 42 ^ $c); assertType('int<0, 255>', $c | 42); assertType('int<0, 42>', $c & 42); +} - /** @var int<0, 20> $x */ - $x = 0; - /** @var int<0, 20> $y */ - $y = 0; +/** + * @param int<0, 20> $x + * @param int<0, 20> $y + */ +function smallRanges(int $x, int $y): void { assertType('int<0, 31>', $x ^ $y); assertType('int<0, 31>', $x | $y); assertType('int<0, 20>', $x & $y); +} - // AND with ranges of different sizes +/** + * @param int<0, 255> $a + * @param int<0, 20> $x + */ +function differentRangeSizes(int $a, int $x): void { assertType('int<0, 20>', $a & $x); assertType('int<0, 20>', $x & $a); +} - // Unbounded ranges stay int - /** @var int<0, max> $unbounded */ - $unbounded = 0; +/** + * @param int<0, max> $unbounded + * @param int<0, 255> $a + */ +function unboundedRanges(int $unbounded, int $a): void { assertType('int', $unbounded ^ $a); assertType('int', $unbounded | $a); +} - // Negative ranges stay int for XOR/OR - /** @var int<-10, 10> $signed */ - $signed = 0; +/** + * @param int<-10, 10> $signed + * @param int<0, 20> $x + */ +function negativeRanges(int $signed, int $x): void { assertType('int', $signed ^ $x); assertType('int', $signed | $x); +} - // Bitwise NOT preserves range bounds +/** + * @param int<0, 255> $a + * @param int<0, 20> $x + * @param int $minBounded + * @param int<-5, max> $maxBounded + */ +function bitwiseNot(int $a, int $x, int $minBounded, int $maxBounded): void { assertType('int<-256, -1>', ~$a); assertType('int<-21, -1>', ~$x); - - /** @var int $minBounded */ - $minBounded = 0; assertType('int<-11, max>', ~$minBounded); - - /** @var int<-5, max> $maxBounded */ - $maxBounded = 0; assertType('int', ~$maxBounded); +} - // Compound assignment operators - /** @var int<0, 255> $d */ - $d = 0; +/** + * @param int<0, 255> $d + * @param int<0, 255> $a + */ +function compoundAssignment(int $d, int $a): void { $d &= $a; assertType('int<0, 255>', $d); } From b9159534837208def83669a3cb14d666c7300365 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 13 Aug 2026 11:31:43 +0200 Subject: [PATCH 3/8] Update bug-14654.php --- tests/PHPStan/Analyser/nsrt/bug-14654.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14654.php b/tests/PHPStan/Analyser/nsrt/bug-14654.php index 878c8f8134e..b7ad930956e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14654.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14654.php @@ -74,12 +74,3 @@ function bitwiseNot(int $a, int $x, int $minBounded, int $maxBounded): void { assertType('int<-11, max>', ~$minBounded); assertType('int', ~$maxBounded); } - -/** - * @param int<0, 255> $d - * @param int<0, 255> $a - */ -function compoundAssignment(int $d, int $a): void { - $d &= $a; - assertType('int<0, 255>', $d); -} From a01c1bb36bb31526240eb1b644133508dd696349 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 13 Aug 2026 10:09:02 +0000 Subject: [PATCH 4/8] Compute bitwise range bounds from the numeric-coerced operands The range computation ran on the raw operand types, so an operand that only becomes an integer after numeric coercion (`$integer & '3'`, `$integer & true`) was not recognized and the result fell back to plain `int` - a regression against the previous constant-integer handling, which used `toNumber()`. Move the computation after the string/error handling and feed it the `toNumber()` results, captured before `optimizeScalarType()` widens integer ranges to `int`. Because the string and error cases are already handled at that point, a single non-negative operand now suffices to bound the AND result: the result's bits are a subset of that operand's bits. Co-Authored-By: Claude Opus 5 --- .../InitializerExprTypeResolver.php | 61 +++++++++++++------ tests/PHPStan/Analyser/nsrt/bug-14654.php | 14 +++++ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index bea1712c579..7e999347af8 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1065,10 +1065,9 @@ public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCall return $result; } - $bitwiseAndRange = $this->computeBitwiseAndRange($leftType, $rightType); - if ($bitwiseAndRange !== null) { - return $bitwiseAndRange; - } + // computed before optimizeScalarType() below widens integer ranges to int + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); if ($result === self::IS_SCALAR_TYPE) { $leftType = $this->optimizeScalarType($leftType); @@ -1095,6 +1094,11 @@ public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCall return new ErrorType(); } + $bitwiseAndRange = $this->computeBitwiseAndRange($leftNumberType, $rightNumberType); + if ($bitwiseAndRange !== null) { + return $bitwiseAndRange; + } + return new IntegerType(); } @@ -1121,10 +1125,9 @@ public function getBitwiseOrType(Expr $left, Expr $right, callable $getTypeCallb return $result; } - $bitwiseOrRange = $this->computeBitwiseOrXorRange($leftType, $rightType); - if ($bitwiseOrRange !== null) { - return $bitwiseOrRange; - } + // computed before optimizeScalarType() below widens integer ranges to int + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); if ($result === self::IS_SCALAR_TYPE) { $leftType = $this->optimizeScalarType($leftType); @@ -1151,6 +1154,11 @@ public function getBitwiseOrType(Expr $left, Expr $right, callable $getTypeCallb return new ErrorType(); } + $bitwiseOrRange = $this->computeBitwiseOrXorRange($leftNumberType, $rightNumberType); + if ($bitwiseOrRange !== null) { + return $bitwiseOrRange; + } + return new IntegerType(); } @@ -1177,10 +1185,9 @@ public function getBitwiseXorType(Expr $left, Expr $right, callable $getTypeCall return $result; } - $bitwiseXorRange = $this->computeBitwiseOrXorRange($leftType, $rightType); - if ($bitwiseXorRange !== null) { - return $bitwiseXorRange; - } + // computed before optimizeScalarType() below widens integer ranges to int + $leftNumberType = $leftType->toNumber(); + $rightNumberType = $rightType->toNumber(); if ($result === self::IS_SCALAR_TYPE) { $leftType = $this->optimizeScalarType($leftType); @@ -1207,6 +1214,11 @@ public function getBitwiseXorType(Expr $left, Expr $right, callable $getTypeCall return new ErrorType(); } + $bitwiseXorRange = $this->computeBitwiseOrXorRange($leftNumberType, $rightNumberType); + if ($bitwiseXorRange !== null) { + return $bitwiseXorRange; + } + return new IntegerType(); } @@ -2015,26 +2027,35 @@ private function getNonNegativeIntegerBounds(Type $type): ?array return null; } - private function computeBitwiseAndRange(Type $leftType, Type $rightType): ?Type + /** + * Expects the operands already converted with toNumber(). + * + * A single non-negative operand is enough to bound the result: the result's bits + * are a subset of that operand's bits, so it stays within int<0, thatMax>. + */ + private function computeBitwiseAndRange(Type $leftNumberType, Type $rightNumberType): ?Type { - $leftBounds = $this->getNonNegativeIntegerBounds($leftType); - $rightBounds = $this->getNonNegativeIntegerBounds($rightType); + $leftBounds = $this->getNonNegativeIntegerBounds($leftNumberType); + $rightBounds = $this->getNonNegativeIntegerBounds($rightNumberType); if ($leftBounds !== null && $rightBounds !== null) { return IntegerRangeType::fromInterval(0, min($leftBounds[1], $rightBounds[1])); } - if ($leftBounds !== null && $rightType->isInteger()->yes()) { + if ($leftBounds !== null) { return IntegerRangeType::fromInterval(0, $leftBounds[1]); } - if ($rightBounds !== null && $leftType->isInteger()->yes()) { + if ($rightBounds !== null) { return IntegerRangeType::fromInterval(0, $rightBounds[1]); } return null; } - private function computeBitwiseOrXorRange(Type $leftType, Type $rightType): ?Type + /** + * Expects the operands already converted with toNumber(). + */ + private function computeBitwiseOrXorRange(Type $leftNumberType, Type $rightNumberType): ?Type { - $leftBounds = $this->getNonNegativeIntegerBounds($leftType); - $rightBounds = $this->getNonNegativeIntegerBounds($rightType); + $leftBounds = $this->getNonNegativeIntegerBounds($leftNumberType); + $rightBounds = $this->getNonNegativeIntegerBounds($rightNumberType); if ($leftBounds === null || $rightBounds === null) { return null; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14654.php b/tests/PHPStan/Analyser/nsrt/bug-14654.php index b7ad930956e..597612aa24c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14654.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14654.php @@ -25,6 +25,18 @@ function withConstantOperand(int $c): void { assertType('int<0, 42>', $c & 42); } +/** + * @param int<0, 255> $c + */ +function withOperandCoercedToInteger(int $integer, int $c): void { + assertType('int<0, 3>', $integer & '3'); + assertType('int<0, 3>', '3' & $integer); + assertType('int<0, 1>', $integer & true); + assertType('int<0, 255>', $c ^ '3'); + assertType('int<0, 255>', $c | '3'); + assertType('int<0, 3>', $c & '3'); +} + /** * @param int<0, 20> $x * @param int<0, 20> $y @@ -51,6 +63,7 @@ function differentRangeSizes(int $a, int $x): void { function unboundedRanges(int $unbounded, int $a): void { assertType('int', $unbounded ^ $a); assertType('int', $unbounded | $a); + assertType('int<0, 255>', $unbounded & $a); } /** @@ -60,6 +73,7 @@ function unboundedRanges(int $unbounded, int $a): void { function negativeRanges(int $signed, int $x): void { assertType('int', $signed ^ $x); assertType('int', $signed | $x); + assertType('int<0, 20>', $signed & $x); } /** From eb52d7e7cdcae67e5b96f75ed7542daf9c39050f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 13 Aug 2026 10:19:54 +0000 Subject: [PATCH 5/8] Derive the bit-propagation shifts from the native integer width The unrolled shifts (1, 2, 4, 8, 16, 32) already covered all 64 bits - the last doubling step is `>> 32`, it is not a 32-bit cutoff - but written out like that it reads as if the mask stopped at 32 bits. Looping while the shift stays below PHP_INT_SIZE * 8 says the same thing explicitly and also adapts to 32-bit builds of PHP. Co-Authored-By: Claude Opus 5 --- src/Reflection/InitializerExprTypeResolver.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 7e999347af8..519b84f4e50 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -122,6 +122,7 @@ use function str_starts_with; use function strtolower; use const INF; +use const PHP_INT_SIZE; #[AutowiredService] final class InitializerExprTypeResolver @@ -2064,14 +2065,16 @@ private function computeBitwiseOrXorRange(Type $leftNumberType, Type $rightNumbe return IntegerRangeType::fromInterval(0, $upperBound); } + /** + * Propagates the highest set bit of a non-negative value into all lower bits: + * 200 becomes 255, 10 becomes 15. + */ private static function allBitsMask(int $value): int { - $value |= $value >> 1; - $value |= $value >> 2; - $value |= $value >> 4; - $value |= $value >> 8; - $value |= $value >> 16; - $value |= $value >> 32; + for ($shift = 1; $shift < PHP_INT_SIZE * 8; $shift *= 2) { + $value |= $value >> $shift; + } + return $value; } From a8b9ee3f75c612723dfb3cbdf9c6a321d7be3b42 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 13 Aug 2026 13:02:40 +0200 Subject: [PATCH 6/8] Update InitializerExprTypeResolver.php --- src/Reflection/InitializerExprTypeResolver.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 519b84f4e50..09a139f39ba 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2071,9 +2071,12 @@ private function computeBitwiseOrXorRange(Type $leftNumberType, Type $rightNumbe */ private static function allBitsMask(int $value): int { - for ($shift = 1; $shift < PHP_INT_SIZE * 8; $shift *= 2) { - $value |= $value >> $shift; - } + $value |= $value >> 1; + $value |= $value >> 2; + $value |= $value >> 4; + $value |= $value >> 8; + $value |= $value >> 16; + $value |= $value >> 32; return $value; } From a4d4080bd8cc12f552c084133f331110789d6601 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 13 Aug 2026 13:09:32 +0200 Subject: [PATCH 7/8] Update InitializerExprTypeResolver.php --- src/Reflection/InitializerExprTypeResolver.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 09a139f39ba..4c035b3cc07 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -122,7 +122,6 @@ use function str_starts_with; use function strtolower; use const INF; -use const PHP_INT_SIZE; #[AutowiredService] final class InitializerExprTypeResolver From 52a58ebdf6705949f722649eac188069cc16da3d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 13 Aug 2026 12:59:50 +0000 Subject: [PATCH 8/8] Infer int<0, max> for bitwise ops on non-negative operands without a finite max getNonNegativeIntegerBounds() required a finite upper bound, so the analytical path declined whenever a side was int<0, max> - which is what strlen(), count() and friends return. The sign-bit argument does not need a finite max: &, | and ^ of non-negative operands stay non-negative, and for & a single non-negative operand is enough. Co-Authored-By: Claude Opus 5 --- .../InitializerExprTypeResolver.php | 38 ++++++++++++------- tests/PHPStan/Analyser/nsrt/bug-14654.php | 18 +++++++-- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 4c035b3cc07..10a913ac5d9 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2009,15 +2009,16 @@ private function optimizeScalarType(Type $type): Type } /** - * @return array{int, int}|null [min, max] or null if bounds are not known + * @return array{int, int|null}|null [min, max] of a type known to be a non-negative integer, + * with a null max when it has no finite upper bound. Null when the type is not known + * to be a non-negative integer. */ private function getNonNegativeIntegerBounds(Type $type): ?array { if ($type instanceof IntegerRangeType) { $min = $type->getMin(); - $max = $type->getMax(); - if ($min !== null && $min >= 0 && $max !== null) { - return [$min, $max]; + if ($min !== null && $min >= 0) { + return [$min, $type->getMax()]; } return null; } @@ -2032,25 +2033,32 @@ private function getNonNegativeIntegerBounds(Type $type): ?array * * A single non-negative operand is enough to bound the result: the result's bits * are a subset of that operand's bits, so it stays within int<0, thatMax>. + * With no finite max on any non-negative side the result is still int<0, max>. */ private function computeBitwiseAndRange(Type $leftNumberType, Type $rightNumberType): ?Type { $leftBounds = $this->getNonNegativeIntegerBounds($leftNumberType); $rightBounds = $this->getNonNegativeIntegerBounds($rightNumberType); - if ($leftBounds !== null && $rightBounds !== null) { - return IntegerRangeType::fromInterval(0, min($leftBounds[1], $rightBounds[1])); + if ($leftBounds === null && $rightBounds === null) { + return null; } - if ($leftBounds !== null) { - return IntegerRangeType::fromInterval(0, $leftBounds[1]); + + $maxValues = []; + if ($leftBounds !== null && $leftBounds[1] !== null) { + $maxValues[] = $leftBounds[1]; } - if ($rightBounds !== null) { - return IntegerRangeType::fromInterval(0, $rightBounds[1]); + if ($rightBounds !== null && $rightBounds[1] !== null) { + $maxValues[] = $rightBounds[1]; } - return null; + + return IntegerRangeType::fromInterval(0, $maxValues === [] ? null : min($maxValues)); } /** * Expects the operands already converted with toNumber(). + * + * Both operands have to be non-negative: they keep the sign bit of the result clear. + * Without a finite max on either side the result is still int<0, max>. */ private function computeBitwiseOrXorRange(Type $leftNumberType, Type $rightNumberType): ?Type { @@ -2059,9 +2067,11 @@ private function computeBitwiseOrXorRange(Type $leftNumberType, Type $rightNumbe if ($leftBounds === null || $rightBounds === null) { return null; } - $maxValue = max($leftBounds[1], $rightBounds[1]); - $upperBound = self::allBitsMask($maxValue); - return IntegerRangeType::fromInterval(0, $upperBound); + if ($leftBounds[1] === null || $rightBounds[1] === null) { + return IntegerRangeType::fromInterval(0, null); + } + + return IntegerRangeType::fromInterval(0, self::allBitsMask(max($leftBounds[1], $rightBounds[1]))); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-14654.php b/tests/PHPStan/Analyser/nsrt/bug-14654.php index 597612aa24c..3e87f46fb95 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14654.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14654.php @@ -61,19 +61,31 @@ function differentRangeSizes(int $a, int $x): void { * @param int<0, 255> $a */ function unboundedRanges(int $unbounded, int $a): void { - assertType('int', $unbounded ^ $a); - assertType('int', $unbounded | $a); + assertType('int<0, max>', $unbounded ^ $a); + assertType('int<0, max>', $unbounded | $a); assertType('int<0, 255>', $unbounded & $a); } +function nonNegativeUnboundedFunctionCalls(string $a, string $b): void { + assertType('int<0, max>', strlen($a) | strlen($b)); + assertType('int<0, max>', strlen($a) ^ strlen($b)); + assertType('int<0, max>', strlen($a) & strlen($b)); + assertType('int<0, max>', strlen($a) | 8); + assertType('int<0, 8>', strlen($a) & 8); +} + /** * @param int<-10, 10> $signed * @param int<0, 20> $x + * @param int<0, max> $unbounded */ -function negativeRanges(int $signed, int $x): void { +function negativeRanges(int $signed, int $x, int $unbounded): void { assertType('int', $signed ^ $x); assertType('int', $signed | $x); assertType('int<0, 20>', $signed & $x); + assertType('int', $signed ^ $unbounded); + assertType('int', $signed | $unbounded); + assertType('int<0, max>', $signed & $unbounded); } /**