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..ed6b323a05 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14314.php @@ -0,0 +1,118 @@ + $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); + } +};