From bd08780ebc84152ad9f3bd09508bc535b3fd4d3f Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:37:33 +0000 Subject: [PATCH 1/6] Fix count comparison with IntegerRangeType narrowing array too aggressively - Fixed TypeSpecifier to properly compute sizeType for count() comparisons when the compared value is an IntegerRangeType (e.g. int<1, 42>) - In falsey context, use max of range to avoid incorrectly removing arrays whose count falls within the range but can still satisfy the condition - In truthy context, use min of range for correct minimum count constraint - Updated test expectations in bug11480.php and list-count.php to reflect the more correct narrowing behavior - New regression test in tests/PHPStan/Analyser/nsrt/bug-13705.php --- src/Analyser/TypeSpecifier.php | 16 +++++++++- tests/PHPStan/Analyser/nsrt/bug-13705.php | 35 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug11480.php | 2 +- tests/PHPStan/Analyser/nsrt/list-count.php | 10 +++---- 4 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13705.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c433..d3f63ec95cd 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -287,7 +287,21 @@ public function specifyTypesInCondition( $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); } } elseif ($leftType instanceof IntegerRangeType) { - $sizeType = $leftType->shift($offset); + if ($context->falsey() && $leftType->getMax() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + } + } elseif ($leftType->getMin() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + } + } else { + $sizeType = $leftType->shift($offset); + } } else { $sizeType = $leftType; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php new file mode 100644 index 00000000000..d1079d1ea21 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -0,0 +1,35 @@ +', $codes); + $code = random_bytes(16); + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +function whileLoopOriginal(int $length, int $quantity): void +{ + if ($length < 8) { + throw new \InvalidArgumentException(); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} 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); } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 4a90322e538..24bfc6fa63f 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -352,7 +352,7 @@ protected function testOptionalKeysInUnionArray($row): void protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoOrThree, $twoOrMore, int $maxThree, $tenOrEleven, $threeOrMoreInRangeLimit, $threeOrMoreOverRangeLimit): void { if (count($row) >= $twoOrThree) { - assertType('array{0: int, 1: string|null, 2?: int|null}', $row); + assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } @@ -376,25 +376,25 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO } if (count($row) >= $threeOrMoreInRangeLimit) { - assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } if (count($listRow) >= $threeOrMoreInRangeLimit) { - assertType('list{0: string, 1: string, 2: string, 3?: string, 4?: string, 5?: string, 6?: string, 7?: string, 8?: string, 9?: string, 10?: string, 11?: string, 12?: string, 13?: string, 14?: string, 15?: string, 16?: string, 17?: string, 18?: string, 19?: string, 20?: string, 21?: string, 22?: string, 23?: string, 24?: string, 25?: string, 26?: string, 27?: string, 28?: string, 29?: string, 30?: string, 31?: string}', $listRow); + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow); } else { assertType('list', $listRow); } if (count($row) >= $threeOrMoreOverRangeLimit) { - assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } if (count($listRow) >= $threeOrMoreOverRangeLimit) { - assertType('non-empty-list', $listRow); + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow); } else { assertType('list', $listRow); } From 9e186b631b41c9e133d02a5d9f9c72a05d20aae6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 21:24:47 +0000 Subject: [PATCH 2/6] Add tests for all IntegerRangeType count comparison conditions Cover all branches in the IntegerRangeType handling in TypeSpecifier: - Falsey context with orEqual (count < bounded range) - Falsey context without orEqual (count <= bounded range) - Truthy context with orEqual (range <= count) - Truthy context without orEqual (range < count) - Fallback branch with unbounded min range (int) - Unbounded max range (int) Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13705.php | 94 +++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php index d1079d1ea21..cc9fc24a19b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -17,6 +17,100 @@ function whileLoop(): void } } +/** + * @param list $arr + * @param int<2, 5> $boundedRange + * @param int<2, max> $unboundedMaxRange + * @param int $unboundedMinRange + */ +function countLessThanRange(array $arr, int $boundedRange, int $unboundedMaxRange, int $unboundedMinRange): void +{ + // count($arr) < $range → inverted to NOT($range <= count($arr)) + // Inner: orEqual=true, false context → falsey + max !== null + orEqual (branch 1) + // Else: orEqual=true, true context → truthy + min !== null + orEqual (branch 3) + if (count($arr) < $boundedRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } + + // count($arr) < unbounded max range → falsey + max is null → fallback via min (branch 3/4) + if (count($arr) < $unboundedMaxRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } + + // count($arr) < unbounded min range → fallback branch (min is null) + if (count($arr) < $unboundedMinRange) { + assertType('list', $arr); + } else { + assertType('list', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function countLessThanOrEqualRange(array $arr, int $boundedRange): void +{ + // count($arr) <= $range → inverted to NOT($range < count($arr)) + // Inner: orEqual=false, false context → falsey + max !== null + !orEqual (branch 2) + // Else: orEqual=false, true context → truthy + min !== null + !orEqual (branch 4) + if (count($arr) <= $boundedRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeGreaterThanOrEqualCount(array $arr, int $boundedRange): void +{ + // $range >= count($arr) → same as count($arr) <= $range + if ($boundedRange >= count($arr)) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeLessThanOrEqualCount(array $arr, int $boundedRange): void +{ + // $range <= count($arr) → direct, orEqual=true + // True context: truthy + orEqual + min !== null (branch 3) + // False context: falsey + orEqual + max !== null (branch 1) + if ($boundedRange <= count($arr)) { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } else { + assertType('list', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeLessThanCount(array $arr, int $boundedRange): void +{ + // $range < count($arr) → direct, orEqual=false + // True context: truthy + !orEqual + min !== null (branch 4) + // False context: falsey + !orEqual + max !== null (branch 2) + if ($boundedRange < count($arr)) { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } else { + assertType('list', $arr); + } +} + function whileLoopOriginal(int $length, int $quantity): void { if ($length < 8) { From 1b549b9fd8806d5e19a66894e117534ec735abc7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 21:53:43 +0000 Subject: [PATCH 3/6] Add exact original issue code as regression test Adds the HelloWorld class from phpstan/phpstan#13705 to ensure the exact code from the bug report no longer produces a false positive. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13705.php | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php index cc9fc24a19b..905984b8163 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -127,3 +127,32 @@ function whileLoopOriginal(int $length, int $quantity): void } } } + +class HelloWorld +{ + private const MIN_LENGTH = 8; + + /** + * @return list + */ + public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array + { + if ($length < self::MIN_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, + 1613666803 + ); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } + return $codes; + } +} From 638c0106a2a11147f06e7abc088c3ddc6d612779 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 07:01:53 +0000 Subject: [PATCH 4/6] Add rule test for bug-13705 to verify no false positive in_array errors Adds a rule test for ImpossibleCheckTypeFunctionCallRule that verifies in_array() inside while loops with count comparisons no longer produces false "will always evaluate to false" errors. Co-Authored-By: Claude Opus 4.6 --- ...mpossibleCheckTypeFunctionCallRuleTest.php | 6 ++ .../Rules/Comparison/data/bug-13705.php | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13705.php diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 5e61f488d05..fd7fae2dbfe 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1202,6 +1202,12 @@ public function testBug14429(): void $this->analyse([__DIR__ . '/data/bug-14429.php'], []); } + public function testBug13705(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13705.php'], []); + } + public function testBug13799(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13705.php b/tests/PHPStan/Rules/Comparison/data/bug-13705.php new file mode 100644 index 00000000000..f3a9e3cf150 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13705.php @@ -0,0 +1,61 @@ += 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +class HelloWorld +{ + private const MIN_LENGTH = 8; + + /** + * @return list + */ + public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array + { + if ($length < self::MIN_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, + 1613666803 + ); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } + return $codes; + } +} From 66ffa81fc07d3380f1a3a88dabf4d7036db1701c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 07:01:59 +0000 Subject: [PATCH 5/6] Fix IntegerRangeType count narrowing for falsey context with unbounded max When the range has no upper bound (e.g. int<1, max>), the falsey context should not narrow using the min value, as this incorrectly eliminates arrays that could satisfy the condition for some (but not all) values in the range. Only set sizeType when the context is truthy and min is available, or when falsey and max is available. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 13 +++++++------ tests/PHPStan/Analyser/nsrt/bug-4700.php | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index d3f63ec95cd..126f0579532 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -280,6 +280,7 @@ public function specifyTypesInCondition( ) { $argType = $scope->getType($expr->right->getArgs()[0]->value); + $sizeType = null; if ($leftType instanceof ConstantIntegerType) { if ($orEqual) { $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); @@ -293,22 +294,22 @@ public function specifyTypesInCondition( } else { $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); } - } elseif ($leftType->getMin() !== null) { + } elseif ($context->truthy() && $leftType->getMin() !== null) { if ($orEqual) { $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); } else { $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); } - } else { - $sizeType = $leftType->shift($offset); } } else { $sizeType = $leftType; } - $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); + if ($sizeType !== null) { + $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-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); } }; From e05edaa7bd42ddec58205b19ca793d96cbab9b85 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 07:14:41 +0000 Subject: [PATCH 6/6] Reuse nsrt/bug-13705.php in rule test instead of duplicating it Co-Authored-By: Claude Opus 4.6 --- ...mpossibleCheckTypeFunctionCallRuleTest.php | 2 +- .../Rules/Comparison/data/bug-13705.php | 61 ------------------- 2 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13705.php diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index fd7fae2dbfe..1af4465cd9d 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1205,7 +1205,7 @@ public function testBug14429(): void public function testBug13705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-13705.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13705.php'], []); } public function testBug13799(): void diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13705.php b/tests/PHPStan/Rules/Comparison/data/bug-13705.php deleted file mode 100644 index f3a9e3cf150..00000000000 --- a/tests/PHPStan/Rules/Comparison/data/bug-13705.php +++ /dev/null @@ -1,61 +0,0 @@ -= 1 && count($codes) < $quantity) { - $code = ''; - for ($i = 0; $i < $length; $i++) { - $code .= 'x'; - } - if (!in_array($code, $codes, true)) { - $codes[] = $code; - } - } -} - -class HelloWorld -{ - private const MIN_LENGTH = 8; - - /** - * @return list - */ - public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array - { - if ($length < self::MIN_LENGTH) { - throw new \InvalidArgumentException( - $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, - 1613666803 - ); - } - $codes = []; - while ($quantity >= 1 && count($codes) < $quantity) { - $code = ''; - for ($i = 0; $i < $length; $i++) { - $code .= 'x'; - } - if (!in_array($code, $codes, true)) { - $codes[] = $code; - } - } - return $codes; - } -}