Skip to content

Commit 4a8e20f

Browse files
ondrejmirtesclaude
andcommitted
Do not map mixed nested in invariant type arguments to strict mixed
At levels 9 and 10, RuleLevelHelper maps mixed to StrictMixedType in both the accepting and the accepted type. An object type that inherits its type arguments through @extends or @implements is not reached by that traversal, so passing ArrBox (extends Box<array<mixed>>) where Box<array<mixed>> is expected compared Box<array<strict-mixed>> against Box<array<mixed>> and failed the invariant equals() check. Invariant type arguments are compared with equals(), so mapping the mixed nested in them only matters when one side is mapped and the other is not. Map just a top-level mixed there, which variance checks still treat leniently, and keep traversing covariant and contravariant arguments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GepwyFN1G3REAm5DMYRQht
1 parent 9aafec2 commit 4a8e20f

6 files changed

Lines changed: 294 additions & 13 deletions

File tree

‎src/Rules/RuleLevelHelper.php‎

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
use PHPStan\Type\ErrorType;
1515
use PHPStan\Type\Generic\GenericObjectType;
1616
use PHPStan\Type\Generic\TemplateMixedType;
17+
use PHPStan\Type\Generic\TemplateType;
18+
use PHPStan\Type\Generic\TemplateTypeVariance;
1719
use PHPStan\Type\IntersectionType;
1820
use PHPStan\Type\MixedType;
1921
use PHPStan\Type\NeverType;
@@ -86,24 +88,81 @@ private function transformCommonType(Type $type): Type
8688
return $type;
8789
}
8890

89-
return TypeTraverser::map($type, function (Type $type, callable $traverse) {
90-
if ($type instanceof TemplateMixedType) {
91-
if ($this->checkExplicitMixed) {
92-
return $type->toStrictMixedType();
93-
}
91+
return TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
92+
if ($type instanceof GenericObjectType && !$type instanceof TemplateType) {
93+
return $this->transformGenericObjectTypeArguments($type, $traverse);
94+
}
95+
96+
return $this->transformMixedType($type) ?? $traverse($type);
97+
});
98+
}
99+
100+
private function transformMixedType(Type $type): ?Type
101+
{
102+
if ($type instanceof TemplateMixedType) {
103+
if ($this->checkExplicitMixed) {
104+
return $type->toStrictMixedType();
94105
}
106+
}
107+
if (
108+
$type instanceof MixedType
109+
&& (
110+
($type->isExplicitMixed() && $this->checkExplicitMixed)
111+
|| (!$type->isExplicitMixed() && $this->checkImplicitMixed)
112+
)
113+
) {
114+
return new StrictMixedType();
115+
}
116+
117+
return null;
118+
}
119+
120+
/**
121+
* Invariant type arguments are compared with equals(), so mapping mixed nested in them
122+
* only changes the outcome when one side is mapped and the other is not - like the
123+
* type arguments an accepted object type inherits through @extends, which a traversal
124+
* does not reach. A top-level mixed is still mapped, as variance checks treat it leniently.
125+
*
126+
* @param callable(Type): Type $traverse
127+
*/
128+
private function transformGenericObjectTypeArguments(GenericObjectType $type, callable $traverse): Type
129+
{
130+
$classReflection = $type->getClassReflection();
131+
if ($classReflection === null) {
132+
return $traverse($type);
133+
}
134+
135+
$templateTypes = $classReflection->typeMapToList($classReflection->getTemplateTypeMap());
136+
$variances = $type->getVariances();
137+
$typesChanged = false;
138+
$types = [];
139+
foreach ($type->getTypes() as $i => $argument) {
140+
$templateType = $templateTypes[$i] ?? null;
95141
if (
96-
$type instanceof MixedType
97-
&& (
98-
($type->isExplicitMixed() && $this->checkExplicitMixed)
99-
|| (!$type->isExplicitMixed() && $this->checkImplicitMixed)
100-
)
142+
($variances[$i] ?? TemplateTypeVariance::createInvariant())->invariant()
143+
&& $templateType instanceof TemplateType
144+
&& $templateType->getVariance()->invariant()
101145
) {
102-
return new StrictMixedType();
146+
$newArgument = $this->transformMixedType($argument) ?? $argument;
147+
} else {
148+
$newArgument = $traverse($argument);
103149
}
104150

105-
return $traverse($type);
106-
});
151+
$types[] = $newArgument;
152+
if ($newArgument === $argument) {
153+
continue;
154+
}
155+
156+
$typesChanged = true;
157+
}
158+
159+
$subtractedType = $type->getSubtractedType();
160+
$newSubtractedType = $subtractedType !== null ? $traverse($subtractedType) : null;
161+
if (!$typesChanged && $newSubtractedType === $subtractedType) {
162+
return $type;
163+
}
164+
165+
return new GenericObjectType($type->getClassName(), $types, $newSubtractedType, variances: $variances);
107166
}
108167

109168
/**

‎tests/PHPStan/Rules/Classes/InstantiationRuleTest.php‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,4 +810,18 @@ public function testBug4548(): void
810810
]);
811811
}
812812

813+
public function testBug14002(): void
814+
{
815+
$this->checkExplicitMixed = true;
816+
$this->checkImplicitMixed = true;
817+
$this->analyse([__DIR__ . '/data/bug-14002.php'], []);
818+
}
819+
820+
public function testBug14002Part2(): void
821+
{
822+
$this->checkExplicitMixed = true;
823+
$this->checkImplicitMixed = true;
824+
$this->analyse([__DIR__ . '/data/bug-14002-2.php'], []);
825+
}
826+
813827
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14002Part2;
6+
7+
use Attribute;
8+
9+
/**
10+
* @template T
11+
*/
12+
class A {
13+
14+
}
15+
16+
/** @template T of object */
17+
#[Attribute(Attribute::TARGET_CLASS)]
18+
final class Entity
19+
{
20+
/** @phpstan-param class-string<EntityRepository<T>>|null $repositoryClass */
21+
public function __construct(
22+
public readonly string|null $repositoryClass = null,
23+
public readonly bool $readOnly = false,
24+
) {
25+
}
26+
}
27+
28+
/**
29+
* @template T
30+
* @extends EntityRepository<A<T>>
31+
*/
32+
class B extends EntityRepository {
33+
34+
}
35+
36+
/**
37+
* @template T of object
38+
*/
39+
class EntityRepository {
40+
41+
}
42+
43+
$e = new Entity(B::class);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14002;
6+
7+
use Attribute;
8+
9+
/**
10+
* @template T
11+
*/
12+
class A {
13+
14+
}
15+
16+
/** @template T of object */
17+
#[Attribute(Attribute::TARGET_CLASS)]
18+
final class Entity
19+
{
20+
/** @phpstan-param class-string<EntityRepository<T>>|null $repositoryClass */
21+
public function __construct(
22+
public readonly string|null $repositoryClass = null,
23+
public readonly bool $readOnly = false,
24+
) {
25+
}
26+
}
27+
28+
/**
29+
* @template T
30+
* @extends EntityRepository<A<mixed>>
31+
*/
32+
class B extends EntityRepository {
33+
34+
}
35+
36+
/**
37+
* @template T of object
38+
*/
39+
class EntityRepository {
40+
41+
}
42+
43+
new Entity(B::class);

‎tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3325,4 +3325,48 @@ public function testBug11510(): void
33253325
]);
33263326
}
33273327

3328+
public function testBug15487(): void
3329+
{
3330+
$this->checkExplicitMixed = true;
3331+
$this->analyse([__DIR__ . '/data/bug-15487.php'], $this->getBug15487Errors());
3332+
}
3333+
3334+
public function testBug15487ImplicitMixed(): void
3335+
{
3336+
$this->checkExplicitMixed = true;
3337+
$this->checkImplicitMixed = true;
3338+
$this->analyse([__DIR__ . '/data/bug-15487.php'], $this->getBug15487Errors());
3339+
}
3340+
3341+
/**
3342+
* @return list<array{0: string, 1: int, 2?: string|null}>
3343+
*/
3344+
private function getBug15487Errors(): array
3345+
{
3346+
return [
3347+
[
3348+
'Parameter #1 $b of function Bug15487\\wantsIntArrBox expects Bug15487\\Box<array<int>>, Bug15487\\PositiveIntArrBox given.',
3349+
56,
3350+
'Template type T on class Bug15487\\Box is not covariant. Learn more: <fg=cyan>https://phpstan.org/blog/whats-up-with-template-covariant</>',
3351+
],
3352+
[
3353+
'Parameter #1 $b of function Bug15487\\wantsIntArrBox expects Bug15487\\Box<array<int>>, Bug15487\\Box<array<int<1, max>>> given.',
3354+
57,
3355+
'Template type T on class Bug15487\\Box is not covariant. Learn more: <fg=cyan>https://phpstan.org/blog/whats-up-with-template-covariant</>',
3356+
],
3357+
[
3358+
'Parameter #1 $b of function Bug15487\\wantsIntArrBox expects Bug15487\\Box<array<int>>, Bug15487\\ArrBox given.',
3359+
58,
3360+
],
3361+
[
3362+
'Parameter #1 $b of function Bug15487\\wantsIntArrBox expects Bug15487\\Box<array<int>>, Bug15487\\Box<array<mixed>> given.',
3363+
59,
3364+
],
3365+
[
3366+
'Parameter #1 $b of function Bug15487\\wantsIntArrCovariantBox expects Bug15487\\CovariantBox<array<int>>, Bug15487\\ArrCovariantBox given.',
3367+
77,
3368+
],
3369+
];
3370+
}
3371+
33283372
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bug15487;
6+
7+
/** @template T */
8+
class Box {}
9+
10+
/** @extends Box<array<mixed>> */
11+
final class ArrBox extends Box {}
12+
13+
/** @param Box<array<mixed>> $b */
14+
function wantsArrBox(Box $b): void {}
15+
16+
function run(): void
17+
{
18+
wantsArrBox(new ArrBox());
19+
}
20+
21+
/** @extends Box<list<mixed>> */
22+
final class ListBox extends Box {}
23+
24+
/** @param Box<list<mixed>> $b */
25+
function wantsListBox(Box $b): void {}
26+
27+
/** @extends Box<iterable<int>> */
28+
final class IterableBox extends Box {}
29+
30+
/** @param Box<iterable<int>> $b */
31+
function wantsIterableBox(Box $b): void {}
32+
33+
/** @extends Box<array<string>> */
34+
final class StringArrBox extends Box {}
35+
36+
/** @param Box<array<string>> $b */
37+
function wantsStringArrBox(Box $b): void {}
38+
39+
/** @extends Box<array<positive-int>> */
40+
final class PositiveIntArrBox extends Box {}
41+
42+
/** @param Box<array<int>> $b */
43+
function wantsIntArrBox(Box $b): void {}
44+
45+
/**
46+
* @param Box<array<mixed>> $declared
47+
* @param Box<array<positive-int>> $positiveInts
48+
*/
49+
function runOthers(Box $declared, Box $positiveInts): void
50+
{
51+
wantsArrBox($declared);
52+
wantsListBox(new ListBox());
53+
wantsIterableBox(new IterableBox());
54+
wantsStringArrBox(new StringArrBox());
55+
56+
wantsIntArrBox(new PositiveIntArrBox());
57+
wantsIntArrBox($positiveInts);
58+
wantsIntArrBox(new ArrBox());
59+
wantsIntArrBox($declared);
60+
}
61+
62+
/** @template-covariant T */
63+
class CovariantBox {}
64+
65+
/** @extends CovariantBox<array<positive-int>> */
66+
final class PositiveIntArrCovariantBox extends CovariantBox {}
67+
68+
/** @extends CovariantBox<array<mixed>> */
69+
final class ArrCovariantBox extends CovariantBox {}
70+
71+
/** @param CovariantBox<array<int>> $b */
72+
function wantsIntArrCovariantBox(CovariantBox $b): void {}
73+
74+
function runCovariant(): void
75+
{
76+
wantsIntArrCovariantBox(new PositiveIntArrCovariantBox());
77+
wantsIntArrCovariantBox(new ArrCovariantBox());
78+
}

0 commit comments

Comments
 (0)