From ef6177f0d4dce3051210cd25ba07617b47a7d77a Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:47:22 +0000 Subject: [PATCH] Fix unsound array type narrowing with count() and range-typed comparisons When `count($arr) < $rangeVar` was used as a while loop condition (or similar), the TypeSpecifier would incorrectly narrow the array to `array{}` inside the loop body. This happened because `specifyTypesForCountFuncCall` treated an IntegerRangeType sizeType in falsey context as "count NOT in range", which is stricter than the actual semantic "count < some value within the range". Skip specifyTypesForCountFuncCall entirely when context is falsey and the left operand is an IntegerRangeType, since the correct narrowing cannot be expressed with the current sizeType abstraction. Update test expectations in bug-4700 and bug11480 which relied on the previous unsound over-narrowing behavior. Fixes https://github.com/phpstan/phpstan/issues/13705 --- src/Analyser/TypeSpecifier.php | 12 +++++-- tests/PHPStan/Analyser/nsrt/bug-13705.php | 40 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-4700.php | 4 +-- tests/PHPStan/Analyser/nsrt/bug11480.php | 2 +- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13705.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index cceef0e7c01..b114ac8028c 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -289,9 +289,15 @@ public function specifyTypesInCondition( $sizeType = $leftType; } - $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); + // For range-typed left operands in falsey context ($left <= count($right) + // being false means count < $left), specifyTypesForCountFuncCall would + // incorrectly narrow the array because "count not in sizeType" is stricter + // than "count < some value in sizeType range". + if (!($context->falsey() && $leftType instanceof IntegerRangeType)) { + $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + $result = $result->unionWith($specifiedTypes); + } } if ( diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php new file mode 100644 index 00000000000..f6813d98959 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -0,0 +1,40 @@ +', $codes); + $code = random_bytes(16); + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +function doWhileLoop(): void +{ + $quantity = random_int(1, 42); + $codes = []; + do { + $code = random_bytes(16); + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } while (count($codes) < $quantity); +} + +function whileLoopSimple(): void +{ + $quantity = random_int(1, 42); + $codes = []; + while (count($codes) < $quantity) { + assertType('list', $codes); + $codes[] = random_bytes(16); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-4700.php b/tests/PHPStan/Analyser/nsrt/bug-4700.php index 24a680e387f..49cea6c59dc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4700.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4700.php @@ -21,8 +21,8 @@ function(array $array, int $count): void { assertType('int<1, 5>', count($a)); assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } else { - assertType('0', count($a)); - assertType('array{}', $a); + assertType('int<0, 5>', count($a)); + assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } }; diff --git a/tests/PHPStan/Analyser/nsrt/bug11480.php b/tests/PHPStan/Analyser/nsrt/bug11480.php index 17077d7bfc7..f80a7237ff2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug11480.php +++ b/tests/PHPStan/Analyser/nsrt/bug11480.php @@ -106,7 +106,7 @@ public function intRangeCount($count): void if (count($x) >= $count) { assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } else { - assertType("array{}", $x); + assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); }