From f99c6ecc6d05d7f3a95a1518d75982c6ec95ec16 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:40:12 +0000 Subject: [PATCH 1/2] Fix phpstan/phpstan#14314: Incorrect count narrowing removes unrelated constant arrays from union - Added fallback in TypeSpecifier::specifyTypesForCountFuncCall for falsey context - When recursive truthy call fails (remaining size includes 0), directly filter constant arrays by their exact sizes instead of using TypeCombinator::remove - This avoids the issue where remove() considers a shorter constant array as a supertype of a longer one, incorrectly eliminating both from the union - New regression test in tests/PHPStan/Analyser/nsrt/bug-14314.php --- src/Analyser/TypeSpecifier.php | 21 +++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14314.php | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14314.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index ed8dba3e7a..0d312f865a 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1294,6 +1294,27 @@ private function specifyTypesForCountFuncCall( return $result; } } + + // Fallback: directly filter constant arrays by their exact sizes. + // This avoids using TypeCombinator::remove() with falsey context, + // which can incorrectly remove arrays whose count doesn't match + // but whose shape is a subtype of the matched array. + $keptTypes = []; + foreach ($type->getConstantArrays() as $arrayType) { + if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { + continue; + } + + $keptTypes[] = $arrayType; + } + if ($keptTypes !== []) { + return $this->create( + $countFuncCall->getArgs()[0]->value, + TypeCombinator::union(...$keptTypes), + $context->negate(), + $scope, + )->setRootExpr($rootExpr); + } } $resultTypes = []; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14314.php b/tests/PHPStan/Analyser/nsrt/bug-14314.php new file mode 100644 index 0000000000..03b467239e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14314.php @@ -0,0 +1,20 @@ + Date: Tue, 17 Mar 2026 17:08:36 +0000 Subject: [PATCH 2/2] Add tests for count narrowing with optional keys and IntegerRange sizeType Address review concerns from PR #5239: - Test that IntegerRange sizeType doesn't incorrectly lose arrays with optional keys (fully covering range correctly removes, partially covering range correctly keeps) - Test sequential count checks preserve narrowing across multiple branches - Test that other variable type specifications are not lost from the early return Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14314.php | 98 +++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14314.php b/tests/PHPStan/Analyser/nsrt/bug-14314.php index 03b467239e..ed6b323a05 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14314.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14314.php @@ -18,3 +18,101 @@ function () { assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches); } }; + +class Bug14314Test +{ + /** + * Test: union with array{} and arrays with optional keys + * @param array{}|array{0: string, 1: string, 2?: string} $arr + */ + public function testOptionalKeysWithEmpty(array $arr): void + { + assertType('array{}|array{0: string, 1: string, 2?: string}', $arr); + if (count($arr) === 3) { + assertType('array{string, string, string}', $arr); + return; + } + // Fallback keeps full array{0: string, 1: string, 2?: string} since its size + // range (2..3) is not fully contained in sizeType (3) + assertType('array{}|array{0: string, 1: string, 2?: string}', $arr); + } + + /** + * Test: IntegerRange sizeType fully covers optional key size range - array is correctly removed + * @param array{}|array{0: string, 1: string, 2?: string} $arr + * @param int<2, 3> $twoOrThree + */ + public function testIntRangeFullyCoveringOptionalKeys(array $arr, int $twoOrThree): void + { + if (count($arr) === $twoOrThree) { + assertType('array{0: string, 1: string, 2?: string}', $arr); + return; + } + // int<2,3> fully covers the optional-key array's size range (2..3), + // so the array is correctly removed in the falsey branch + assertType('array{}', $arr); + } + + /** + * Test: IntegerRange partially covers optional key size range - array is kept + * @param array{}|array{0: string, 1: string, 2?: string, 3?: string} $arr + * @param int<2, 3> $twoOrThree + */ + public function testIntRangePartiallyCoveringOptionalKeys(array $arr, int $twoOrThree): void + { + if (count($arr) === $twoOrThree) { + assertType('array{0: string, 1: string, 2?: string, 3?: string}', $arr); + return; + } + // int<2,3> does NOT fully cover size range (2..4), so the array is kept + assertType('array{}|array{0: string, 1: string, 2?: string, 3?: string}', $arr); + } + + /** + * Test: IntegerRange sizeType with union of constant arrays including array{} + * @param array{}|array{string}|array{string, string, string, string} $arr + * @param int<2, 4> $twoToFour + */ + public function testIntRangeWithUnionAndEmpty(array $arr, int $twoToFour): void + { + if (count($arr) === $twoToFour) { + assertType('array{string, string, string, string}', $arr); + return; + } + assertType('array{}|array{string, string, string, string}|array{string}', $arr); + } +} + +// Test: sequential count checks preserve narrowing correctly +function () { + preg_match('/^(.)$/', '', $m) || preg_match('/^(.)(.)(.)$/', '', $m) || preg_match('/^(.)(.)(.)(.)(.)(.)$/', '', $m); + assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string}', $m); + if (count($m) === 2) { + assertType('array{non-falsy-string, non-empty-string}', $m); + return; + } + assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m); + if (count($m) === 4) { + assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m); + return; + } + assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}', $m); + if (count($m) === 7) { + assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}', $m); + } +}; + +// Test: count narrowing does not lose other variable types +function (int $x) { + preg_match('/^(.)$/', '', $matches) || preg_match('/^(.)(.)(.)$/', '', $matches); + if ($x > 0) { + assertType('int<1, max>', $x); + if (count($matches) === 2) { + assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('int<1, max>', $x); + return; + } + assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches); + assertType('int<1, max>', $x); + } +};