Skip to content

Commit d3838df

Browse files
staabmphpstan-bot
authored andcommitted
Do not map the sealed-never sentinel value type in ConstantArrayType::mapValueType
- In bleeding edge, a sealed constant array carries an `unsealed` descriptor of `[never, never]` (explicit never) meaning "no extra elements". `mapValueType` applied the callback to that sentinel value type, which for `array_map` (and the str_replace family, both routed through `mapValueType`) resolves the callback against a `never` argument and yields an `ErrorType`. - The `ErrorType` was hidden inside the sealed array's unsealed slot, so `UnresolvableTypeHelper` (which traverses the whole type) reported false-positive `function.unresolvableReturnType` / `argument.unresolvableType` errors even though the visible inferred type was correct. - Guard `mapValueType` to leave the unsealed sentinel untouched when the array is sealed (`isUnsealed()->no()`), only mapping the unsealed value type for genuinely (or maybe) unsealed arrays. - Probed the sibling transforms `traverse` / `traverseSimultaneously`, nested `array_map`, `str_replace`, generic identity pass-through, array spread and `+` union: all leaks originate from the single `mapValueType` chokepoint and are fixed at the source; the sibling traversals only propagate types and never plant the `ErrorType`.
1 parent 041824d commit d3838df

4 files changed

Lines changed: 143 additions & 2 deletions

File tree

src/Type/Constant/ConstantArrayType.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3186,8 +3186,12 @@ public function mapValueType(callable $cb): Type
31863186
$newValueTypes[] = $cb($valueType);
31873187
}
31883188

3189-
$newUnsealed = $this->unsealed === null
3190-
? null
3189+
// A sealed array's unsealed value type is the explicit-never sentinel for
3190+
// "no extra elements". Mapping it through $cb would invent a bogus type for
3191+
// elements that cannot exist (e.g. an ErrorType from an enum property fetch
3192+
// on never), so leave the sentinel untouched.
3193+
$newUnsealed = $this->unsealed === null || $this->isUnsealed()->no()
3194+
? $this->unsealed
31913195
: [$this->unsealed[0], $cb($this->unsealed[1])];
31923196

31933197
return $this->recreate(
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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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__ . '/data/bug-14844.php'], []);
52+
}
53+
54+
public static function getAdditionalConfigFiles(): array
55+
{
56+
return [
57+
__DIR__ . '/../../../../conf/bleedingEdge.neon',
58+
];
59+
}
60+
61+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14844Rule;
6+
7+
enum Someenum: string
8+
{
9+
case FOO = 'foo';
10+
}
11+
12+
/**
13+
* @template TReturn
14+
* @param callable(): TReturn $callable
15+
* @return TReturn
16+
*/
17+
function doFoo(callable $callable)
18+
{
19+
return $callable();
20+
}
21+
22+
class A
23+
{
24+
25+
/**
26+
* @return array<string>
27+
*/
28+
public function doBar(): array
29+
{
30+
return doFoo(
31+
fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()),
32+
);
33+
}
34+
35+
}

0 commit comments

Comments
 (0)