Skip to content

Commit 6ee0856

Browse files
phpstan-botclaude
andcommitted
Cover sibling array operations touching the sealed unsealed sentinel
Audit of every ConstantArrayType method that reads or transforms the `[never, never]` unsealed sentinel under bleeding edge: only mapValueType applies a value-projecting callback (array_map's user closure body) that can turn the never sentinel into an ErrorType. The sibling transforms (traverse, traverseSimultaneously, generalizeValues, filterArrayRemovingFalsey, changeKeyCaseArray) only map never to never, and array-rebuilding operations (array_reverse, array_values, array_merge, spread) drop and recreate the sentinel, so none of them plant an ErrorType. These regression tests lock in that behavior across the whole operation family (array_map nesting, str_replace, generic pass-through, +, spread, array_filter, array_reverse, array_values, array_merge, array_change_key_case). They fail without the mapValueType guard and pass with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e29fc6 commit 6ee0856

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

tests/PHPStan/Rules/Functions/Bug14844Test.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ public function testBug14844(): void
5151
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14844.php'], []);
5252
}
5353

54+
#[RequiresPhp('>= 8.1.0')]
55+
public function testBug14844Siblings(): void
56+
{
57+
// Sibling ConstantArrayType operations that also touch the sealed
58+
// `[never, never]` unsealed sentinel must not plant an ErrorType there.
59+
$this->analyse([__DIR__ . '/data/bug-14844-siblings.php'], []);
60+
}
61+
5462
public static function getAdditionalConfigFiles(): array
5563
{
5664
return [
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14844Siblings;
6+
7+
enum Someenum: string
8+
{
9+
case FOO = 'foo';
10+
case BAR = 'bar';
11+
}
12+
13+
/**
14+
* @template TReturn
15+
* @param callable(): TReturn $callable
16+
* @return TReturn
17+
*/
18+
function doFoo(callable $callable)
19+
{
20+
return $callable();
21+
}
22+
23+
/**
24+
* @template T
25+
* @param T $x
26+
* @return T
27+
*/
28+
function identity($x)
29+
{
30+
return $x;
31+
}
32+
33+
/**
34+
* A sealed constant array carries a `[never, never]` unsealed sentinel under
35+
* bleeding edge. Every operation below transforms or passes through that
36+
* sentinel via a different ConstantArrayType method (mapValueType, traverse,
37+
* traverseSimultaneously, generalizeValues, ...). None of them may leak an
38+
* ErrorType into the sentinel slot, which UnresolvableTypeHelper would flag as
39+
* a bogus "contains unresolvable type" error on these generic calls.
40+
*/
41+
class A
42+
{
43+
44+
public function arrayMap(): void
45+
{
46+
// mapValueType
47+
doFoo(fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()));
48+
}
49+
50+
public function nestedArrayMap(): void
51+
{
52+
// mapValueType applied to a mapped array
53+
doFoo(fn () => array_map(
54+
static fn (string $v) => strtoupper($v),
55+
array_map(fn (Someenum $type) => $type->value, Someenum::cases()),
56+
));
57+
}
58+
59+
public function strReplace(): void
60+
{
61+
// ReplaceFunctionsDynamicReturnTypeExtension -> mapValueType
62+
doFoo(fn () => str_replace('f', 'F', array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
63+
}
64+
65+
public function genericIdentity(): void
66+
{
67+
// template resolution / traverse over a sealed array passed by value
68+
identity(array_map(fn (Someenum $type) => $type->value, Someenum::cases()));
69+
}
70+
71+
public function spread(): void
72+
{
73+
doFoo(fn () => [...array_map(fn (Someenum $type) => $type->value, Someenum::cases())]);
74+
}
75+
76+
public function arrayUnion(): void
77+
{
78+
// unionArrays
79+
$mapped = array_map(fn (Someenum $type) => $type->value, Someenum::cases());
80+
doFoo(fn () => $mapped + $mapped);
81+
}
82+
83+
public function arrayReverse(): void
84+
{
85+
// reverseArray
86+
identity(array_reverse(array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
87+
}
88+
89+
public function arrayValues(): void
90+
{
91+
// getValuesArray
92+
identity(array_values(array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
93+
}
94+
95+
public function arrayFilter(): void
96+
{
97+
// filterArrayRemovingFalsey
98+
identity(array_filter(array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
99+
}
100+
101+
public function arrayMerge(): void
102+
{
103+
// mergeArrays
104+
$mapped = array_map(fn (Someenum $type) => $type->value, Someenum::cases());
105+
identity(array_merge($mapped, $mapped));
106+
}
107+
108+
public function plainSealedArray(): void
109+
{
110+
// a plain sealed array literal also carries the sentinel
111+
identity(['foo', 'bar']);
112+
identity(array_reverse(['foo', 'bar']));
113+
identity(array_values(['foo', 'bar']));
114+
identity(array_change_key_case(['foo' => 1, 'bar' => 2]));
115+
}
116+
117+
}

0 commit comments

Comments
 (0)