Skip to content

Commit d9a423c

Browse files
phpstan-botclaude
andcommitted
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 <noreply@anthropic.com>
1 parent f99c6ec commit d9a423c

1 file changed

Lines changed: 98 additions & 0 deletions

File tree

tests/PHPStan/Analyser/nsrt/bug-14314.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,101 @@ function () {
1818
assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches);
1919
}
2020
};
21+
22+
class Bug14314Test
23+
{
24+
/**
25+
* Test: union with array{} and arrays with optional keys
26+
* @param array{}|array{0: string, 1: string, 2?: string} $arr
27+
*/
28+
public function testOptionalKeysWithEmpty(array $arr): void
29+
{
30+
assertType('array{}|array{0: string, 1: string, 2?: string}', $arr);
31+
if (count($arr) === 3) {
32+
assertType('array{string, string, string}', $arr);
33+
return;
34+
}
35+
// Fallback keeps full array{0: string, 1: string, 2?: string} since its size
36+
// range (2..3) is not fully contained in sizeType (3)
37+
assertType('array{}|array{0: string, 1: string, 2?: string}', $arr);
38+
}
39+
40+
/**
41+
* Test: IntegerRange sizeType fully covers optional key size range - array is correctly removed
42+
* @param array{}|array{0: string, 1: string, 2?: string} $arr
43+
* @param int<2, 3> $twoOrThree
44+
*/
45+
public function testIntRangeFullyCoveringOptionalKeys(array $arr, int $twoOrThree): void
46+
{
47+
if (count($arr) === $twoOrThree) {
48+
assertType('array{0: string, 1: string, 2?: string}', $arr);
49+
return;
50+
}
51+
// int<2,3> fully covers the optional-key array's size range (2..3),
52+
// so the array is correctly removed in the falsey branch
53+
assertType('array{}', $arr);
54+
}
55+
56+
/**
57+
* Test: IntegerRange partially covers optional key size range - array is kept
58+
* @param array{}|array{0: string, 1: string, 2?: string, 3?: string} $arr
59+
* @param int<2, 3> $twoOrThree
60+
*/
61+
public function testIntRangePartiallyCoveringOptionalKeys(array $arr, int $twoOrThree): void
62+
{
63+
if (count($arr) === $twoOrThree) {
64+
assertType('array{0: string, 1: string, 2?: string, 3?: string}', $arr);
65+
return;
66+
}
67+
// int<2,3> does NOT fully cover size range (2..4), so the array is kept
68+
assertType('array{}|array{0: string, 1: string, 2?: string, 3?: string}', $arr);
69+
}
70+
71+
/**
72+
* Test: IntegerRange sizeType with union of constant arrays including array{}
73+
* @param array{}|array{string}|array{string, string, string, string} $arr
74+
* @param int<2, 4> $twoToFour
75+
*/
76+
public function testIntRangeWithUnionAndEmpty(array $arr, int $twoToFour): void
77+
{
78+
if (count($arr) === $twoToFour) {
79+
assertType('array{string, string, string, string}', $arr);
80+
return;
81+
}
82+
assertType('array{}|array{string, string, string, string}|array{string}', $arr);
83+
}
84+
}
85+
86+
// Test: sequential count checks preserve narrowing correctly
87+
function () {
88+
preg_match('/^(.)$/', '', $m) || preg_match('/^(.)(.)(.)$/', '', $m) || preg_match('/^(.)(.)(.)(.)(.)(.)$/', '', $m);
89+
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);
90+
if (count($m) === 2) {
91+
assertType('array{non-falsy-string, non-empty-string}', $m);
92+
return;
93+
}
94+
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);
95+
if (count($m) === 4) {
96+
assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m);
97+
return;
98+
}
99+
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);
100+
if (count($m) === 7) {
101+
assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}', $m);
102+
}
103+
};
104+
105+
// Test: count narrowing does not lose other variable types
106+
function (int $x) {
107+
preg_match('/^(.)$/', '', $matches) || preg_match('/^(.)(.)(.)$/', '', $matches);
108+
if ($x > 0) {
109+
assertType('int<1, max>', $x);
110+
if (count($matches) === 2) {
111+
assertType('array{non-falsy-string, non-empty-string}', $matches);
112+
assertType('int<1, max>', $x);
113+
return;
114+
}
115+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches);
116+
assertType('int<1, max>', $x);
117+
}
118+
};

0 commit comments

Comments
 (0)