Skip to content

Commit bcfd2f1

Browse files
phpstan-botclaude
authored andcommitted
Track ClosureTypeResolver's body-walk depth guard per closure node instead of globally
- `ClosureTypeResolver::getClosureType()` stopped walking closure bodies once a single static counter reached 2. The guard exists for a self-by-ref closure re-walking itself, but the counter was shared by all closures, so the third nested closure passed to a generic call (pre-walked by `gatherClosureArgType()` to infer templates) fell back to its declared signature. Templates inferred from its return type became `mixed`, and the outer argument was rejected against itself (`argument.type`). - `$resolveClosureTypeDepth` is now an `array<int, int>` keyed by the closure node's `spl_object_id()`, so a closure re-entering itself is still cut off at depth 2 while different closures nest freely. Mutually recursive by-ref closures still terminate (bounded by 2 per node). - Ported to the turbo-ext mirror (`ClosureTypeResolver.cpp`): the C++ counter becomes a list of the object handles being walked; regenerated the property declaration in `generated/ClosureTypeResolver.h`. - Covers arrow functions, `function () {}` closures (with and without declared return types), generators, instance and static method templates (`Box::map()`), `@var`-annotated receivers inside a closure argument, and nested `array_map()` with untyped arrow functions (previously `array{array{array{mixed}}}`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4ef9ab7 commit bcfd2f1

6 files changed

Lines changed: 313 additions & 15 deletions

File tree

‎src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@
6363
final class ClosureTypeResolver implements PerFileAnalysisResettable
6464
{
6565

66-
private static int $resolveClosureTypeDepth = 0;
66+
/**
67+
* Body-walk depth per closure node (spl_object_id()) - a self-by-ref
68+
* closure re-walking itself is cut off, while different closures nested
69+
* in each other's bodies are not.
70+
*
71+
* @var array<int, int>
72+
*/
73+
private static array $resolveClosureTypeDepth = [];
6774

6875
/**
6976
* Per-context resolved closure types, keyed by the closure node. Node
@@ -155,7 +162,8 @@ public function getClosureType(
155162
if (array_key_exists($cacheKey, $cachedTypes)) {
156163
return $this->createClosureTypeFromCache($scope, $expr, $parameters, $isVariadic, $cachedTypes[$cacheKey], $contextFree);
157164
}
158-
if (self::$resolveClosureTypeDepth >= 2) {
165+
$exprId = spl_object_id($expr);
166+
if ((self::$resolveClosureTypeDepth[$exprId] ?? 0) >= 2) {
159167
return new ClosureType(
160168
$parameters,
161169
$scope->getFunctionType($expr->returnType, false, false),
@@ -172,7 +180,7 @@ public function getClosureType(
172180

173181
$arrowFunctionImpurePoints = [];
174182
$invalidateExpressions = [];
175-
self::$resolveClosureTypeDepth++;
183+
self::$resolveClosureTypeDepth[$exprId] = (self::$resolveClosureTypeDepth[$exprId] ?? 0) + 1;
176184
$walkStorage = new ExpressionResultStorage();
177185
$arrowScope->pushExpressionResultStorage($walkStorage);
178186
try {
@@ -208,7 +216,9 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
208216
);
209217
} finally {
210218
$arrowScope->popExpressionResultStorage();
211-
self::$resolveClosureTypeDepth--;
219+
if (--self::$resolveClosureTypeDepth[$exprId] === 0) {
220+
unset(self::$resolveClosureTypeDepth[$exprId]);
221+
}
212222
}
213223
$throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints());
214224
$impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints());
@@ -220,7 +230,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
220230
return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $cacheKey, $contextFree);
221231
}
222232

223-
self::$resolveClosureTypeDepth++;
233+
self::$resolveClosureTypeDepth[$exprId] = (self::$resolveClosureTypeDepth[$exprId] ?? 0) + 1;
224234

225235
$closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters);
226236
$closureReturnStatements = [];
@@ -271,7 +281,9 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
271281
}, StatementContext::createTopLevel())->toPublic();
272282
} finally {
273283
$closureScope->popExpressionResultStorage();
274-
self::$resolveClosureTypeDepth--;
284+
if (--self::$resolveClosureTypeDepth[$exprId] === 0) {
285+
unset(self::$resolveClosureTypeDepth[$exprId]);
286+
}
275287
}
276288

277289
$throwPoints = $closureStatementResult->getThrowPoints();
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15406;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
final class Node
10+
{
11+
/** @param list<self> $children */
12+
public function __construct(public readonly array $children, public readonly int $value) {}
13+
}
14+
15+
/**
16+
* @template T
17+
* @template U
18+
*
19+
* @param list<T> $list
20+
* @param callable(T): iterable<U> $fn
21+
*
22+
* @return list<U>
23+
*/
24+
function collect(array $list, callable $fn): array
25+
{
26+
$result = [];
27+
foreach ($list as $value) {
28+
foreach ($fn($value) as $item) {
29+
$result[] = $item;
30+
}
31+
}
32+
33+
return $result;
34+
}
35+
36+
/**
37+
* @param list<Node> $nodes
38+
*/
39+
function twoLevels(array $nodes): void
40+
{
41+
assertType('list<int>', collect($nodes, static fn (Node $node): array
42+
=> collect($node->children, static fn (Node $child): array => [$child->value]),
43+
));
44+
}
45+
46+
/**
47+
* @param list<Node> $nodes
48+
*/
49+
function threeLevels(array $nodes): void
50+
{
51+
assertType('list<int>', collect($nodes, static fn (Node $node): array
52+
=> collect($node->children, static fn (Node $child): array
53+
=> collect($child->children, static fn (Node $grandChild): array => [$grandChild->value]),
54+
),
55+
));
56+
}
57+
58+
/**
59+
* @param list<Node> $nodes
60+
*/
61+
function threeLevelsGenerators(array $nodes): void
62+
{
63+
assertType('list<int>', collect($nodes, static fn (Node $node): iterable
64+
=> yield from collect($node->children, static fn (Node $child): iterable
65+
=> yield from collect($child->children, static fn (Node $grandChild): iterable => yield $grandChild->value),
66+
),
67+
));
68+
}
69+
70+
/**
71+
* @param list<Node> $nodes
72+
*/
73+
function threeLevelsClosures(array $nodes): void
74+
{
75+
assertType('list<int>', collect($nodes, static function (Node $node): array {
76+
return collect($node->children, static function (Node $child): array {
77+
return collect($child->children, static function (Node $grandChild): array {
78+
return [$grandChild->value];
79+
});
80+
});
81+
}));
82+
}
83+
84+
/**
85+
* @param list<Node> $nodes
86+
*/
87+
function threeLevelsClosuresWithoutReturnTypes(array $nodes): void
88+
{
89+
assertType('list<int>', collect($nodes, static function (Node $node) {
90+
return collect($node->children, static function (Node $child) {
91+
return collect($child->children, static function (Node $grandChild) {
92+
return [$grandChild->value];
93+
});
94+
});
95+
}));
96+
}
97+
98+
/**
99+
* @param list<Node> $nodes
100+
*/
101+
function fiveLevels(array $nodes): void
102+
{
103+
assertType('list<int>', collect($nodes, static fn (Node $a): array
104+
=> collect($a->children, static fn (Node $b): array
105+
=> collect($b->children, static fn (Node $c): array
106+
=> collect($c->children, static fn (Node $d): array
107+
=> collect($d->children, static fn (Node $e): array => [$e->value]),
108+
),
109+
),
110+
),
111+
));
112+
}
113+
114+
/**
115+
* @template T
116+
*/
117+
final class Box
118+
{
119+
/** @param T $value */
120+
public function __construct(public mixed $value)
121+
{
122+
}
123+
124+
/**
125+
* @template U
126+
* @param callable(T): U $callback
127+
* @return Box<U>
128+
*/
129+
public function map(callable $callback): Box
130+
{
131+
return new Box($callback($this->value));
132+
}
133+
134+
/**
135+
* @template V
136+
* @template U
137+
* @param V $value
138+
* @param callable(V): U $callback
139+
* @return Box<U>
140+
*/
141+
public static function mapStatic(mixed $value, callable $callback): Box
142+
{
143+
return new Box($callback($value));
144+
}
145+
}
146+
147+
/**
148+
* @template T
149+
* @param callable(): T $callback
150+
* @return T
151+
*/
152+
function run(callable $callback): mixed
153+
{
154+
return $callback();
155+
}
156+
157+
function boxThreeLevels(): void
158+
{
159+
$box = new Box(1);
160+
assertType('Bug15406\Box<Bug15406\Box<Bug15406\Box<array{k: 1}>>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): Box => (new Box($j))->map(fn (int $k): array => ['k' => $k]))));
161+
}
162+
163+
function boxStaticThreeLevels(): void
164+
{
165+
assertType('Bug15406\Box<Bug15406\Box<Bug15406\Box<array{k: 1}>>>', Box::mapStatic(1, fn (int $i): Box => Box::mapStatic($i, fn (int $j): Box => Box::mapStatic($j, fn (int $k): array => ['k' => $k]))));
166+
}
167+
168+
function boxTwoLevels(): void
169+
{
170+
/** @var Box<int> $box */
171+
$box = new Box(1);
172+
assertType('Bug15406\Box<Bug15406\Box<array{j: int}>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j])));
173+
}
174+
175+
run(function (): void {
176+
$box = new Box(1);
177+
assertType('Bug15406\Box<Bug15406\Box<array{j: 1}>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j])));
178+
179+
/** @var Box<int> $box */
180+
$box = new Box(1);
181+
assertType('Bug15406\Box<Bug15406\Box<array{j: int}>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j])));
182+
});
183+
184+
function arrayMapFourLevels(): void
185+
{
186+
assertType('array{array{array{array{array{1}}}}}', array_map(fn ($x) => array_map(fn ($y) => array_map(fn ($z) => array_map(fn ($w) => [$w], [$z]), [$y]), [$x]), [1]));
187+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3350,6 +3350,14 @@ public function testBug15432(): void
33503350
]);
33513351
}
33523352

3353+
#[RequiresPhp('>= 8.1.0')]
3354+
public function testBug15406(): void
3355+
{
3356+
$this->checkExplicitMixed = true;
3357+
$this->checkImplicitMixed = true;
3358+
$this->analyse([__DIR__ . '/data/bug-15406.php'], []);
3359+
}
3360+
33533361
public function testBug15487(): void
33543362
{
33553363
$this->checkExplicitMixed = true;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15406Rule;
6+
7+
final class Node
8+
{
9+
/** @param list<self> $children */
10+
public function __construct(public readonly array $children, public readonly int $value) {}
11+
}
12+
13+
/**
14+
* @template T
15+
* @template U
16+
*
17+
* @param list<T> $list
18+
* @param callable(T): iterable<U> $fn
19+
*
20+
* @return list<U>
21+
*/
22+
function collect(array $list, callable $fn): array
23+
{
24+
$result = [];
25+
foreach ($list as $value) {
26+
foreach ($fn($value) as $item) {
27+
$result[] = $item;
28+
}
29+
}
30+
31+
return $result;
32+
}
33+
34+
/**
35+
* @param list<Node> $nodes
36+
*/
37+
function twoLevels(array $nodes): void
38+
{
39+
$result = collect($nodes, static fn (Node $node): array
40+
=> collect($node->children, static fn (Node $child): array => [$child->value]),
41+
);
42+
}
43+
44+
/**
45+
* @param list<Node> $nodes
46+
*/
47+
function threeLevels(array $nodes): void
48+
{
49+
$result = collect($nodes, static fn (Node $node): array
50+
=> collect($node->children, static fn (Node $child): array
51+
=> collect($child->children, static fn (Node $grandChild): array => [$grandChild->value]),
52+
),
53+
);
54+
}
55+
56+
/**
57+
* @param list<Node> $nodes
58+
*/
59+
function threeLevelsGenerators(array $nodes): void
60+
{
61+
$result = collect($nodes, static fn (Node $node): iterable
62+
=> yield from collect($node->children, static fn (Node $child): iterable
63+
=> yield from collect($child->children, static fn (Node $grandChild): iterable => yield $grandChild->value),
64+
),
65+
);
66+
}

0 commit comments

Comments
 (0)