Skip to content

Commit debe6b2

Browse files
committed
Do not map the sealed-array never marker in ConstantArrayType::mapValueType()
- `ConstantArrayType::mapValueType()` ran the callback over the unsealed value slot even for sealed arrays, where that slot is just the explicit-`never` marker. Callbacks that are not pure type transformers (e.g. the `array_map` callback evaluation, `array_walk`, array-dim assignments in loops) would replace the marker with whatever they returned and leak that type into the otherwise-sealed shape. - For the reported case, evaluating `fn (SomeEnum $type) => $type->value` against the `never` marker produces an `ErrorType`, which `describe()` hides but `UnresolvableTypeHelper` (via `TypeTraverser`) finds, wrongly reporting "contains unresolvable type" on a fully-resolved `array{'foo'}`. - Only transform the unsealed value type when the array is genuinely unsealed; otherwise keep the existing marker untouched. This central fix covers every caller of `mapValueType()`. - Added a unit test on `ConstantArrayType::mapValueType()` and an AnalyserIntegrationTest reproducing the array_map + enum scenario under bleeding edge.
1 parent 041824d commit debe6b2

4 files changed

Lines changed: 72 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+
// For a sealed array the unsealed slot is just the explicit-never marker;
3190+
// running the callback over it would replace the marker with whatever the
3191+
// callback returns (e.g. an ErrorType from evaluating an array_map callback
3192+
// on `never`) and leak that type into the otherwise-sealed shape.
3193+
$newUnsealed = $this->unsealed === null || !$this->isUnsealed()->yes()
3194+
? $this->unsealed
31913195
: [$this->unsealed[0], $cb($this->unsealed[1])];
31923196

31933197
return $this->recreate(

tests/PHPStan/Analyser/AnalyserIntegrationTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,13 @@ public function testBug14707(): void
16081608
$this->assertNoErrors($errors);
16091609
}
16101610

1611+
#[RequiresPhp('>= 8.1.0')]
1612+
public function testBug14844(): void
1613+
{
1614+
$errors = $this->runAnalyse(__DIR__ . '/data/bug-14844.php');
1615+
$this->assertNoErrors($errors);
1616+
}
1617+
16111618
/**
16121619
* @param string[]|null $allAnalysedFiles
16131620
* @return list<Error>
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 Bug14844;
6+
7+
enum SomeEnum: string
8+
{
9+
case FOO = 'foo';
10+
}
11+
12+
class A
13+
{
14+
15+
/**
16+
* @return array<string>
17+
*/
18+
public function doBar(): array
19+
{
20+
return doFoo(
21+
fn () => array_map(fn (SomeEnum $type) => $type->value, SomeEnum::cases()),
22+
);
23+
}
24+
25+
}
26+
27+
/**
28+
* @template TReturn
29+
* @param callable(): TReturn $callable
30+
* @return TReturn
31+
*/
32+
function doFoo(callable $callable)
33+
{
34+
return $callable();
35+
}

tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Closure;
66
use PHPStan\DependencyInjection\BleedingEdgeToggle;
77
use PHPStan\PhpDoc\TypeStringResolver;
8+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
89
use PHPStan\Testing\PHPStanTestCase;
910
use PHPStan\TrinaryLogic;
1011
use PHPStan\Type\Accessory\HasOffsetType;
@@ -13,6 +14,7 @@
1314
use PHPStan\Type\BooleanType;
1415
use PHPStan\Type\CallableType;
1516
use PHPStan\Type\ClassStringType;
17+
use PHPStan\Type\ErrorType;
1618
use PHPStan\Type\GeneralizePrecision;
1719
use PHPStan\Type\Generic\GenericClassStringType;
1820
use PHPStan\Type\Generic\TemplateTypeFactory;
@@ -1619,6 +1621,28 @@ public function testSealedness(): void
16191621
});
16201622
}
16211623

1624+
public function testMapValueTypeKeepsSealedMarker(): void
1625+
{
1626+
BleedingEdgeToggle::withBleedingEdge(true, function () {
1627+
$array = new ConstantArrayType(
1628+
[new ConstantIntegerType(0)],
1629+
[new StringType()],
1630+
[1],
1631+
[],
1632+
TrinaryLogic::createYes(),
1633+
);
1634+
$this->assertSame(TrinaryLogic::createYes()->describe(), $array->isSealed()->describe());
1635+
1636+
// Evaluating an array_map callback against the sealed `never` marker
1637+
// produces an ErrorType (e.g. `$enum->value` on `never`). That ErrorType
1638+
// must not leak into the otherwise-sealed shape's unsealed slot.
1639+
$mapped = $array->mapValueType(static fn (Type $type): Type => $type instanceof NeverType ? new ErrorType() : new ConstantStringType('foo'));
1640+
$this->assertInstanceOf(ConstantArrayType::class, $mapped);
1641+
$this->assertSame(TrinaryLogic::createYes()->describe(), $mapped->isSealed()->describe());
1642+
$this->assertNull((new UnresolvableTypeHelper())->getUnresolvableType($mapped));
1643+
});
1644+
}
1645+
16221646
public static function dataGetArraySize(): iterable
16231647
{
16241648
$cases = [];

0 commit comments

Comments
 (0)