Skip to content

Commit d6abe7f

Browse files
phpstan-botstaabmclaude
authored
Prevent "Unresolvable type" errors when mapping constant arrays (#5936)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Markus Staab <maggus.staab@googlemail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de>
1 parent 041824d commit d6abe7f

5 files changed

Lines changed: 280 additions & 0 deletions

File tree

src/Type/Constant/ConstantArrayType.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ public function __construct(
181181
if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) {
182182
$unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey();
183183
}
184+
if ($unsealed[0] instanceof NeverType && $unsealed[0]->isExplicit()) {
185+
$unsealed[1] = new NeverType(true);
186+
}
184187
} elseif (BleedingEdgeToggle::isBleedingEdge()) {
185188
$never = new NeverType(true);
186189
$unsealed = [$never, $never];
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14844;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
enum Someenum: string
10+
{
11+
case FOO = 'foo';
12+
}
13+
14+
/**
15+
* @template TReturn
16+
* @param callable(): TReturn $callable
17+
* @return TReturn
18+
*/
19+
function doFoo(callable $callable)
20+
{
21+
return $callable();
22+
}
23+
24+
class A
25+
{
26+
27+
/**
28+
* @return array<string>
29+
*/
30+
public function doBar(): array
31+
{
32+
assertType("array{'foo'}", doFoo(
33+
fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()),
34+
));
35+
36+
return doFoo(
37+
fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()),
38+
);
39+
}
40+
41+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PHPStan\Rules\FunctionCallParametersCheck;
6+
use PHPStan\Rules\NullsafeCheck;
7+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
8+
use PHPStan\Rules\Properties\PropertyReflectionFinder;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleLevelHelper;
11+
use PHPStan\Testing\RuleTestCase;
12+
use PHPUnit\Framework\Attributes\RequiresPhp;
13+
14+
/**
15+
* @extends RuleTestCase<CallToFunctionParametersRule>
16+
*/
17+
class Bug14844Test extends RuleTestCase
18+
{
19+
20+
protected function getRule(): Rule
21+
{
22+
$broker = self::createReflectionProvider();
23+
return new CallToFunctionParametersRule(
24+
$broker,
25+
new FunctionCallParametersCheck(
26+
new RuleLevelHelper(
27+
$broker,
28+
checkNullables: true,
29+
checkThisOnly: false,
30+
checkUnionTypes: true,
31+
checkExplicitMixed: true,
32+
checkImplicitMixed: true,
33+
checkBenevolentUnionTypes: false,
34+
discoveringSymbolsTip: true,
35+
),
36+
new NullsafeCheck(),
37+
new UnresolvableTypeHelper(),
38+
new PropertyReflectionFinder(),
39+
$broker,
40+
checkArgumentTypes: true,
41+
checkArgumentsPassedByReference: true,
42+
checkExtraArguments: true,
43+
checkMissingTypehints: true,
44+
),
45+
);
46+
}
47+
48+
#[RequiresPhp('>= 8.1.0')]
49+
public function testBug14844(): void
50+
{
51+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14844.php'], []);
52+
}
53+
54+
#[RequiresPhp('>= 8.1.0')]
55+
public function testBug14844ClassConst(): void
56+
{
57+
// Same bug as testBug14844, but the array_map callback fetches a class
58+
// constant (`$type::FOO`) instead of an enum-case property. It also routes
59+
// through mapValueType and must not plant an ErrorType in the sentinel.
60+
$this->analyse([__DIR__ . '/data/bug-14844-class-const.php'], []);
61+
}
62+
63+
#[RequiresPhp('>= 8.1.0')]
64+
public function testBug14844Siblings(): void
65+
{
66+
// Sibling ConstantArrayType operations that also touch the sealed
67+
// `[never, never]` unsealed sentinel must not plant an ErrorType there.
68+
$this->analyse([__DIR__ . '/data/bug-14844-siblings.php'], []);
69+
}
70+
71+
public static function getAdditionalConfigFiles(): array
72+
{
73+
return [
74+
__DIR__ . '/../../../../conf/bleedingEdge.neon',
75+
];
76+
}
77+
78+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14844ClassConst;
6+
7+
final class Someclass
8+
{
9+
10+
public const FOO = 'foo';
11+
12+
}
13+
14+
/**
15+
* @template TReturn
16+
* @param callable(): TReturn $callable
17+
* @return TReturn
18+
*/
19+
function doFoo(callable $callable)
20+
{
21+
return $callable();
22+
}
23+
24+
class A
25+
{
26+
27+
/**
28+
* @return array<string>
29+
*/
30+
public function doBar(): array
31+
{
32+
// Same sealed-sentinel bug as bug-14844, but the mapped value is a
33+
// class-constant fetch (`$type::FOO`) instead of an enum-case fetch.
34+
return doFoo(
35+
fn () => array_map(fn (Someclass $type) => $type::FOO, [new Someclass()]),
36+
);
37+
}
38+
39+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
* @impure
29+
*/
30+
function identity($x)
31+
{
32+
return $x;
33+
}
34+
35+
/**
36+
* A sealed constant array carries a `[never, never]` unsealed sentinel under
37+
* bleeding edge. Every operation below transforms or passes through that
38+
* sentinel via a different ConstantArrayType method (mapValueType, traverse,
39+
* traverseSimultaneously, generalizeValues, ...). None of them may leak an
40+
* ErrorType into the sentinel slot, which UnresolvableTypeHelper would flag as
41+
* a bogus "contains unresolvable type" error on these generic calls.
42+
*/
43+
class A
44+
{
45+
46+
public function arrayMap(): void
47+
{
48+
// mapValueType
49+
doFoo(fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()));
50+
}
51+
52+
public function nestedArrayMap(): void
53+
{
54+
// mapValueType applied to a mapped array
55+
doFoo(fn () => array_map(
56+
static fn (string $v) => strtoupper($v),
57+
array_map(fn (Someenum $type) => $type->value, Someenum::cases()),
58+
));
59+
}
60+
61+
public function strReplace(): void
62+
{
63+
// ReplaceFunctionsDynamicReturnTypeExtension -> mapValueType
64+
doFoo(fn () => str_replace('f', 'F', array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
65+
}
66+
67+
public function genericIdentity(): void
68+
{
69+
// template resolution / traverse over a sealed array passed by value
70+
identity(array_map(fn (Someenum $type) => $type->value, Someenum::cases()));
71+
}
72+
73+
public function spread(): void
74+
{
75+
doFoo(fn () => [...array_map(fn (Someenum $type) => $type->value, Someenum::cases())]);
76+
}
77+
78+
public function arrayUnion(): void
79+
{
80+
// unionArrays
81+
$mapped = array_map(fn (Someenum $type) => $type->value, Someenum::cases());
82+
doFoo(fn () => $mapped + $mapped);
83+
}
84+
85+
public function arrayReverse(): void
86+
{
87+
// reverseArray
88+
identity(array_reverse(array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
89+
}
90+
91+
public function arrayValues(): void
92+
{
93+
// getValuesArray
94+
identity(array_values(array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
95+
}
96+
97+
public function arrayFilter(): void
98+
{
99+
// filterArrayRemovingFalsey
100+
identity(array_filter(array_map(fn (Someenum $type) => $type->value, Someenum::cases())));
101+
}
102+
103+
public function arrayMerge(): void
104+
{
105+
// mergeArrays
106+
$mapped = array_map(fn (Someenum $type) => $type->value, Someenum::cases());
107+
identity(array_merge($mapped, $mapped));
108+
}
109+
110+
public function plainSealedArray(): void
111+
{
112+
// a plain sealed array literal also carries the sentinel
113+
identity(['foo', 'bar']);
114+
identity(array_reverse(['foo', 'bar']));
115+
identity(array_values(['foo', 'bar']));
116+
identity(array_change_key_case(['foo' => 1, 'bar' => 2]));
117+
}
118+
119+
}

0 commit comments

Comments
 (0)