Skip to content

Commit 338b1b6

Browse files
phpstan-botVincentLangletclaude
authored
Report array&callable pass as wrong param (#5573)
Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0beee48 commit 338b1b6

4 files changed

Lines changed: 284 additions & 0 deletions

File tree

src/Type/IntersectionType.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,17 @@ public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsRes
304304
static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes),
305305
);
306306

307+
// lazyMaxMin can short-circuit to Yes when array<mixed> (inside e.g. array&callable
308+
// or array&hasOffsetValue) is accepted by a specific array type like array<int>,
309+
// because MixedType::isAcceptedBy() always returns Yes. The isSuperTypeOf check
310+
// considers the intersection holistically and catches these false positives.
311+
if ($result->yes()) {
312+
$isSuperType = $acceptingType->isSuperTypeOf($this);
313+
if ($isSuperType->no()) {
314+
return $isSuperType->toAcceptsResult();
315+
}
316+
}
317+
307318
if ($this->isOversizedArray()->yes()) {
308319
if (!$result->no()) {
309320
return AcceptsResult::createYes();

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4025,4 +4025,41 @@ public function testBug13272(): void
40254025
$this->analyse([__DIR__ . '/data/bug-13272.php'], []);
40264026
}
40274027

4028+
public function testBug14549(): void
4029+
{
4030+
$this->checkThisOnly = false;
4031+
$this->checkNullables = true;
4032+
$this->checkUnionTypes = true;
4033+
$this->analyse([__DIR__ . '/data/bug-14549-bis.php'], [
4034+
[
4035+
'Parameter #1 $param of method Bug14549Bis\Foo::callArrayInt() expects array<int>, array&callable given.',
4036+
33,
4037+
],
4038+
[
4039+
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayStringString() expects array{string, string}, array&callable(): mixed given.',
4040+
34,
4041+
],
4042+
[
4043+
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.',
4044+
36,
4045+
],
4046+
[
4047+
'Parameter #1 $param of method Bug14549Bis\Foo::callArrayInt() expects array<int>, array&callable given.',
4048+
44,
4049+
],
4050+
[
4051+
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayStringString() expects array{string, string}, array&callable(): mixed given.',
4052+
45,
4053+
],
4054+
[
4055+
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.',
4056+
47,
4057+
],
4058+
[
4059+
'Parameter #1 $param of method Bug14549Bis\Foo::callArrayString() expects array<string>, array given.',
4060+
58,
4061+
],
4062+
]);
4063+
}
4064+
40284065
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace Bug14549Bis;
4+
5+
class Foo
6+
{
7+
8+
/** @param array<int> $param */
9+
public function callArrayInt(array $param): void
10+
{
11+
}
12+
13+
/** @param array{string, string} $param */
14+
public function callConstantArrayStringString(array $param): void
15+
{
16+
}
17+
18+
/** @param array{object|string, string} $param */
19+
public function callConstantArrayObjectOrStringString(array $param): void
20+
{
21+
}
22+
23+
/** @param array{object|string, string, string} $param */
24+
public function callConstantArrayObjectOrStringStringString(array $param): void
25+
{
26+
}
27+
28+
/**
29+
* @param callable-array $task
30+
*/
31+
public function doCallWithCallableArray(array $task): void
32+
{
33+
$this->callArrayInt($task);
34+
$this->callConstantArrayStringString($task);
35+
$this->callConstantArrayObjectOrStringString($task);
36+
$this->callConstantArrayObjectOrStringStringString($task);
37+
}
38+
39+
/**
40+
* @param callable&array $task
41+
*/
42+
public function doCallWithCallableAndArray(array $task): void
43+
{
44+
$this->callArrayInt($task);
45+
$this->callConstantArrayStringString($task);
46+
$this->callConstantArrayObjectOrStringString($task);
47+
$this->callConstantArrayObjectOrStringStringString($task);
48+
}
49+
50+
/** @param array<string> $param */
51+
public function callArrayString(array $param): void
52+
{
53+
}
54+
55+
public function doCallWithHasOffsetValue(array $arr): void
56+
{
57+
if (isset($arr[1]) && $arr[1] === 1) {
58+
$this->callArrayString($arr);
59+
$this->callArrayInt($arr);
60+
}
61+
}
62+
63+
}

tests/PHPStan/Type/IntersectionTypeTest.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,132 @@ public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedR
109109
);
110110
}
111111

112+
/**
113+
* @return Iterator<int, array{Type, Type, TrinaryLogic}>
114+
*/
115+
public static function dataIsAcceptedBy(): Iterator
116+
{
117+
// array&callable isAcceptedBy array - success
118+
yield [
119+
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
120+
new ArrayType(new MixedType(), new MixedType()),
121+
TrinaryLogic::createYes(),
122+
];
123+
124+
// array&callable isAcceptedBy array<int> - failure
125+
yield [
126+
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
127+
new ArrayType(new MixedType(), new IntegerType()),
128+
TrinaryLogic::createNo(),
129+
];
130+
131+
// array&callable isAcceptedBy constantArray{stdClass, string} - maybe
132+
yield [
133+
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
134+
new ConstantArrayType(
135+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
136+
[new UnionType([new ObjectType('stdClass'), new StringType()]), new StringType()],
137+
),
138+
TrinaryLogic::createMaybe(),
139+
];
140+
141+
// array&callable isAcceptedBy constantArray{string, string} - maybe
142+
yield [
143+
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
144+
new ConstantArrayType(
145+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
146+
[new StringType(), new StringType()],
147+
),
148+
TrinaryLogic::createMaybe(),
149+
];
150+
151+
// array&hasOffsetValue isAcceptedBy array - success
152+
yield [
153+
new IntersectionType([
154+
new ArrayType(new MixedType(), new MixedType()),
155+
new NonEmptyArrayType(),
156+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
157+
]),
158+
new ArrayType(new MixedType(), new MixedType()),
159+
TrinaryLogic::createYes(),
160+
];
161+
162+
// array&hasOffsetValue isAcceptedBy array - failure
163+
yield [
164+
new IntersectionType([
165+
new ArrayType(new MixedType(), new MixedType()),
166+
new NonEmptyArrayType(),
167+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
168+
]),
169+
new ArrayType(new MixedType(), new StringType()),
170+
TrinaryLogic::createNo(),
171+
];
172+
173+
// array&hasOffsetValue isAcceptedBy array<int> - success (matching value type)
174+
yield [
175+
new IntersectionType([
176+
new ArrayType(new MixedType(), new MixedType()),
177+
new NonEmptyArrayType(),
178+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
179+
]),
180+
new ArrayType(new MixedType(), new IntegerType()),
181+
TrinaryLogic::createYes(),
182+
];
183+
184+
// array&hasOffsetValue isAcceptedBy constantArray{int, int} - success
185+
yield [
186+
new IntersectionType([
187+
new ArrayType(new MixedType(), new MixedType()),
188+
new NonEmptyArrayType(),
189+
new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()),
190+
]),
191+
new ConstantArrayType(
192+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
193+
[new IntegerType(), new IntegerType()],
194+
),
195+
TrinaryLogic::createMaybe(),
196+
];
197+
198+
// array&hasOffsetValue isAcceptedBy constantArray{string, string} - failure
199+
yield [
200+
new IntersectionType([
201+
new ArrayType(new MixedType(), new MixedType()),
202+
new NonEmptyArrayType(),
203+
new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()),
204+
]),
205+
new ConstantArrayType(
206+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
207+
[new StringType(), new StringType()],
208+
),
209+
TrinaryLogic::createNo(),
210+
];
211+
212+
// array&hasOffsetValue(3, int) isAcceptedBy array<int>|array<string> - yes (array<int> accepts it)
213+
yield [
214+
new IntersectionType([
215+
new ArrayType(new MixedType(), new MixedType()),
216+
new NonEmptyArrayType(),
217+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
218+
]),
219+
new UnionType([
220+
new ArrayType(new MixedType(), new IntegerType()),
221+
new ArrayType(new MixedType(), new StringType()),
222+
]),
223+
TrinaryLogic::createYes(),
224+
];
225+
}
226+
227+
#[DataProvider('dataIsAcceptedBy')]
228+
public function testIsAcceptedBy(Type $type, Type $acceptingType, TrinaryLogic $expectedResult): void
229+
{
230+
$actualResult = $acceptingType->accepts($type, true)->result;
231+
$this->assertSame(
232+
$expectedResult->describe(),
233+
$actualResult->describe(),
234+
sprintf('%s -> isAcceptedBy(%s)', $type->describe(VerbosityLevel::precise()), $acceptingType->describe(VerbosityLevel::precise())),
235+
);
236+
}
237+
112238
public static function dataIsCallable(): array
113239
{
114240
return [
@@ -362,6 +488,53 @@ public static function dataIsSubTypeOf(): Iterator
362488
]),
363489
TrinaryLogic::createYes(),
364490
];
491+
492+
// array&callable isSubTypeOf array - success
493+
yield [
494+
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
495+
new ArrayType(new MixedType(), new MixedType()),
496+
TrinaryLogic::createYes(),
497+
];
498+
499+
// array&callable isSubTypeOf array<int> - failure
500+
yield [
501+
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
502+
new ArrayType(new MixedType(), new IntegerType()),
503+
TrinaryLogic::createNo(),
504+
];
505+
506+
// array&hasOffsetValue isSubTypeOf array - success
507+
yield [
508+
new IntersectionType([
509+
new ArrayType(new MixedType(), new MixedType()),
510+
new NonEmptyArrayType(),
511+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
512+
]),
513+
new ArrayType(new MixedType(), new MixedType()),
514+
TrinaryLogic::createYes(),
515+
];
516+
517+
// array&hasOffsetValue isSubTypeOf array<int> - maybe
518+
yield [
519+
new IntersectionType([
520+
new ArrayType(new MixedType(), new MixedType()),
521+
new NonEmptyArrayType(),
522+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
523+
]),
524+
new ArrayType(new MixedType(), new IntegerType()),
525+
TrinaryLogic::createMaybe(),
526+
];
527+
528+
// array&hasOffsetValue isSubTypeOf array<string> - failure
529+
yield [
530+
new IntersectionType([
531+
new ArrayType(new MixedType(), new MixedType()),
532+
new NonEmptyArrayType(),
533+
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
534+
]),
535+
new ArrayType(new MixedType(), new StringType()),
536+
TrinaryLogic::createNo(),
537+
];
365538
}
366539

367540
#[DataProvider('dataIsSubTypeOf')]

0 commit comments

Comments
 (0)