From 5f3ff04e3d658392a4a7b7f882257b7b77f847e4 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:08:58 +0000 Subject: [PATCH 1/2] Fix phpstan/phpstan#11129: Prevent arithmetic on union types from cascading to ErrorType - When a union type like `int|non-falsy-string|float` is used in arithmetic, `toNumber()` returns ErrorType because ErrorType (from the string member) extends MixedType and absorbs all other types in the union - Added `filterNumberTypeFromUnion()` in `resolveCommonMath()` that extracts valid numeric types from union members, skipping ErrorType only when it comes from string members (since string-to-number conversion always produces a result in PHP) - This prevents loop variable types from degrading to `mixed` when a variable alternates between int and string (e.g., `$pos = '0' . $pos` in a foreach loop) - New regression test in tests/PHPStan/Analyser/nsrt/bug-11129b.php - Removed 3 baseline entries and 1 inline ignore that are no longer needed --- phpstan-baseline.neon | 18 -------- .../InitializerExprTypeResolver.php | 42 ++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-11129b.php | 36 ++++++++++++++++ 3 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11129b.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 971e51ec9fc..b38d75c3745 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -435,24 +435,6 @@ parameters: count: 1 path: src/Reflection/InitializerExprTypeResolver.php - - - rawMessage: Binary operation "*" between bool|float|int|string|null and bool|float|int|string|null results in an error. - identifier: binaryOp.invalid - count: 1 - path: src/Reflection/InitializerExprTypeResolver.php - - - - rawMessage: Binary operation "+" between bool|float|int|string|null and bool|float|int|string|null results in an error. - identifier: binaryOp.invalid - count: 1 - path: src/Reflection/InitializerExprTypeResolver.php - - - - rawMessage: Binary operation "-" between bool|float|int|string|null and bool|float|int|string|null results in an error. - identifier: binaryOp.invalid - count: 1 - path: src/Reflection/InitializerExprTypeResolver.php - - rawMessage: Binary operation "^" between bool|float|int|string|null and bool|float|int|string|null results in an error. identifier: binaryOp.invalid diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index b4578930acf..9a057dc5e7f 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1288,7 +1288,7 @@ public function getDivTypeFromTypes(Expr $left, Expr $right, Type $leftType, Typ return new ErrorType(); } - $resultType = $this->getTypeFromValue($leftNumberType->getValue() / $rightNumberType->getValue()); // @phpstan-ignore binaryOp.invalid + $resultType = $this->getTypeFromValue($leftNumberType->getValue() / $rightNumberType->getValue()); $resultTypes[] = $resultType; } } @@ -2119,6 +2119,13 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri return new ErrorType(); } + if ($leftNumberType instanceof ErrorType) { + $leftNumberType = $this->filterNumberTypeFromUnion($leftType) ?? $leftNumberType; + } + if ($rightNumberType instanceof ErrorType) { + $rightNumberType = $this->filterNumberTypeFromUnion($rightType) ?? $rightNumberType; + } + if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { return new ErrorType(); } @@ -2155,6 +2162,39 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri return $resultType; } + /** + * When toNumber() on a union returns ErrorType because some members (strings) + * can't be proven numeric, try to extract the numeric parts. + * Only skips ErrorType from string members, since string-to-number conversion + * always produces a result in PHP (with possible deprecation warning). + */ + private function filterNumberTypeFromUnion(Type $type): ?Type + { + if (!$type instanceof UnionType) { + return null; + } + + $numberTypes = []; + $hasSkipped = false; + foreach ($type->getTypes() as $memberType) { + $numberType = $memberType->toNumber(); + if ($numberType instanceof ErrorType) { + if ($memberType->isString()->yes()) { + $hasSkipped = true; + continue; + } + return null; + } + $numberTypes[] = $numberType; + } + + if ($numberTypes === [] || !$hasSkipped) { + return null; + } + + return TypeCombinator::union(...$numberTypes); + } + /** * @param ConstantIntegerType|IntegerRangeType $range * @param BinaryOp\Div|BinaryOp\Minus|BinaryOp\Mul|BinaryOp\Plus|BinaryOp\ShiftLeft|BinaryOp\ShiftRight $node diff --git a/tests/PHPStan/Analyser/nsrt/bug-11129b.php b/tests/PHPStan/Analyser/nsrt/bug-11129b.php new file mode 100644 index 00000000000..1202009a708 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11129b.php @@ -0,0 +1,36 @@ + $array */ + public function sayHello(array $array): void + { + $pos = 0; + foreach ($array as $element) { + ++$pos; + + if ($pos < 10) { + $pos = '0' . $pos; + } + } + assertType('0|float|(non-falsy-string&uppercase-string)', $pos); + } + + /** @param array $array */ + public function withPlusOne(array $array): void + { + $pos = 0; + foreach ($array as $element) { + $pos = $pos + 1; + + if ($pos < 10) { + $pos = '0' . $pos; + } + } + assertType('0|float|(non-falsy-string&uppercase-string)', $pos); + } +} From cb65eff56a3a76c597b379b53543d51e9e8844e1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 18:30:31 +0000 Subject: [PATCH 2/2] Revert "Fix phpstan/phpstan#11129: Prevent arithmetic on union types from cascading to ErrorType" This reverts commit 5f3ff04e3d658392a4a7b7f882257b7b77f847e4. --- phpstan-baseline.neon | 18 ++++++++ .../InitializerExprTypeResolver.php | 42 +------------------ tests/PHPStan/Analyser/nsrt/bug-11129b.php | 36 ---------------- 3 files changed, 19 insertions(+), 77 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-11129b.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b38d75c3745..971e51ec9fc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -435,6 +435,24 @@ parameters: count: 1 path: src/Reflection/InitializerExprTypeResolver.php + - + rawMessage: Binary operation "*" between bool|float|int|string|null and bool|float|int|string|null results in an error. + identifier: binaryOp.invalid + count: 1 + path: src/Reflection/InitializerExprTypeResolver.php + + - + rawMessage: Binary operation "+" between bool|float|int|string|null and bool|float|int|string|null results in an error. + identifier: binaryOp.invalid + count: 1 + path: src/Reflection/InitializerExprTypeResolver.php + + - + rawMessage: Binary operation "-" between bool|float|int|string|null and bool|float|int|string|null results in an error. + identifier: binaryOp.invalid + count: 1 + path: src/Reflection/InitializerExprTypeResolver.php + - rawMessage: Binary operation "^" between bool|float|int|string|null and bool|float|int|string|null results in an error. identifier: binaryOp.invalid diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 9a057dc5e7f..b4578930acf 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1288,7 +1288,7 @@ public function getDivTypeFromTypes(Expr $left, Expr $right, Type $leftType, Typ return new ErrorType(); } - $resultType = $this->getTypeFromValue($leftNumberType->getValue() / $rightNumberType->getValue()); + $resultType = $this->getTypeFromValue($leftNumberType->getValue() / $rightNumberType->getValue()); // @phpstan-ignore binaryOp.invalid $resultTypes[] = $resultType; } } @@ -2119,13 +2119,6 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri return new ErrorType(); } - if ($leftNumberType instanceof ErrorType) { - $leftNumberType = $this->filterNumberTypeFromUnion($leftType) ?? $leftNumberType; - } - if ($rightNumberType instanceof ErrorType) { - $rightNumberType = $this->filterNumberTypeFromUnion($rightType) ?? $rightNumberType; - } - if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) { return new ErrorType(); } @@ -2162,39 +2155,6 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri return $resultType; } - /** - * When toNumber() on a union returns ErrorType because some members (strings) - * can't be proven numeric, try to extract the numeric parts. - * Only skips ErrorType from string members, since string-to-number conversion - * always produces a result in PHP (with possible deprecation warning). - */ - private function filterNumberTypeFromUnion(Type $type): ?Type - { - if (!$type instanceof UnionType) { - return null; - } - - $numberTypes = []; - $hasSkipped = false; - foreach ($type->getTypes() as $memberType) { - $numberType = $memberType->toNumber(); - if ($numberType instanceof ErrorType) { - if ($memberType->isString()->yes()) { - $hasSkipped = true; - continue; - } - return null; - } - $numberTypes[] = $numberType; - } - - if ($numberTypes === [] || !$hasSkipped) { - return null; - } - - return TypeCombinator::union(...$numberTypes); - } - /** * @param ConstantIntegerType|IntegerRangeType $range * @param BinaryOp\Div|BinaryOp\Minus|BinaryOp\Mul|BinaryOp\Plus|BinaryOp\ShiftLeft|BinaryOp\ShiftRight $node diff --git a/tests/PHPStan/Analyser/nsrt/bug-11129b.php b/tests/PHPStan/Analyser/nsrt/bug-11129b.php deleted file mode 100644 index 1202009a708..00000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-11129b.php +++ /dev/null @@ -1,36 +0,0 @@ - $array */ - public function sayHello(array $array): void - { - $pos = 0; - foreach ($array as $element) { - ++$pos; - - if ($pos < 10) { - $pos = '0' . $pos; - } - } - assertType('0|float|(non-falsy-string&uppercase-string)', $pos); - } - - /** @param array $array */ - public function withPlusOne(array $array): void - { - $pos = 0; - foreach ($array as $element) { - $pos = $pos + 1; - - if ($pos < 10) { - $pos = '0' . $pos; - } - } - assertType('0|float|(non-falsy-string&uppercase-string)', $pos); - } -}