From bcfd2f181d13bb1ce6f5d3d0cd6fed3ffcd9a108 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:36:04 +0000 Subject: [PATCH] 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` 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 --- .../Helper/ClosureTypeResolver.php | 24 ++- tests/PHPStan/Analyser/nsrt/bug-15406.php | 187 ++++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 8 + .../Rules/Functions/data/bug-15406.php | 66 +++++++ turbo-ext/src/ClosureTypeResolver.cpp | 41 +++- turbo-ext/src/generated/ClosureTypeResolver.h | 2 +- 6 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15406.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-15406.php diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 2a519ebcdea..22d5937a5a7 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -63,7 +63,14 @@ final class ClosureTypeResolver implements PerFileAnalysisResettable { - private static int $resolveClosureTypeDepth = 0; + /** + * Body-walk depth per closure node (spl_object_id()) - a self-by-ref + * closure re-walking itself is cut off, while different closures nested + * in each other's bodies are not. + * + * @var array + */ + private static array $resolveClosureTypeDepth = []; /** * Per-context resolved closure types, keyed by the closure node. Node @@ -155,7 +162,8 @@ public function getClosureType( if (array_key_exists($cacheKey, $cachedTypes)) { return $this->createClosureTypeFromCache($scope, $expr, $parameters, $isVariadic, $cachedTypes[$cacheKey], $contextFree); } - if (self::$resolveClosureTypeDepth >= 2) { + $exprId = spl_object_id($expr); + if ((self::$resolveClosureTypeDepth[$exprId] ?? 0) >= 2) { return new ClosureType( $parameters, $scope->getFunctionType($expr->returnType, false, false), @@ -172,7 +180,7 @@ public function getClosureType( $arrowFunctionImpurePoints = []; $invalidateExpressions = []; - self::$resolveClosureTypeDepth++; + self::$resolveClosureTypeDepth[$exprId] = (self::$resolveClosureTypeDepth[$exprId] ?? 0) + 1; $walkStorage = new ExpressionResultStorage(); $arrowScope->pushExpressionResultStorage($walkStorage); try { @@ -208,7 +216,9 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu ); } finally { $arrowScope->popExpressionResultStorage(); - self::$resolveClosureTypeDepth--; + if (--self::$resolveClosureTypeDepth[$exprId] === 0) { + unset(self::$resolveClosureTypeDepth[$exprId]); + } } $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); @@ -220,7 +230,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $cacheKey, $contextFree); } - self::$resolveClosureTypeDepth++; + self::$resolveClosureTypeDepth[$exprId] = (self::$resolveClosureTypeDepth[$exprId] ?? 0) + 1; $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); $closureReturnStatements = []; @@ -271,7 +281,9 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu }, StatementContext::createTopLevel())->toPublic(); } finally { $closureScope->popExpressionResultStorage(); - self::$resolveClosureTypeDepth--; + if (--self::$resolveClosureTypeDepth[$exprId] === 0) { + unset(self::$resolveClosureTypeDepth[$exprId]); + } } $throwPoints = $closureStatementResult->getThrowPoints(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15406.php b/tests/PHPStan/Analyser/nsrt/bug-15406.php new file mode 100644 index 00000000000..3ef386e164e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15406.php @@ -0,0 +1,187 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15406; + +use function PHPStan\Testing\assertType; + +final class Node +{ + /** @param list $children */ + public function __construct(public readonly array $children, public readonly int $value) {} +} + +/** + * @template T + * @template U + * + * @param list $list + * @param callable(T): iterable $fn + * + * @return list + */ +function collect(array $list, callable $fn): array +{ + $result = []; + foreach ($list as $value) { + foreach ($fn($value) as $item) { + $result[] = $item; + } + } + + return $result; +} + +/** + * @param list $nodes + */ +function twoLevels(array $nodes): void +{ + assertType('list', collect($nodes, static fn (Node $node): array + => collect($node->children, static fn (Node $child): array => [$child->value]), + )); +} + +/** + * @param list $nodes + */ +function threeLevels(array $nodes): void +{ + assertType('list', collect($nodes, static fn (Node $node): array + => collect($node->children, static fn (Node $child): array + => collect($child->children, static fn (Node $grandChild): array => [$grandChild->value]), + ), + )); +} + +/** + * @param list $nodes + */ +function threeLevelsGenerators(array $nodes): void +{ + assertType('list', collect($nodes, static fn (Node $node): iterable + => yield from collect($node->children, static fn (Node $child): iterable + => yield from collect($child->children, static fn (Node $grandChild): iterable => yield $grandChild->value), + ), + )); +} + +/** + * @param list $nodes + */ +function threeLevelsClosures(array $nodes): void +{ + assertType('list', collect($nodes, static function (Node $node): array { + return collect($node->children, static function (Node $child): array { + return collect($child->children, static function (Node $grandChild): array { + return [$grandChild->value]; + }); + }); + })); +} + +/** + * @param list $nodes + */ +function threeLevelsClosuresWithoutReturnTypes(array $nodes): void +{ + assertType('list', collect($nodes, static function (Node $node) { + return collect($node->children, static function (Node $child) { + return collect($child->children, static function (Node $grandChild) { + return [$grandChild->value]; + }); + }); + })); +} + +/** + * @param list $nodes + */ +function fiveLevels(array $nodes): void +{ + assertType('list', collect($nodes, static fn (Node $a): array + => collect($a->children, static fn (Node $b): array + => collect($b->children, static fn (Node $c): array + => collect($c->children, static fn (Node $d): array + => collect($d->children, static fn (Node $e): array => [$e->value]), + ), + ), + ), + )); +} + +/** + * @template T + */ +final class Box +{ + /** @param T $value */ + public function __construct(public mixed $value) + { + } + + /** + * @template U + * @param callable(T): U $callback + * @return Box + */ + public function map(callable $callback): Box + { + return new Box($callback($this->value)); + } + + /** + * @template V + * @template U + * @param V $value + * @param callable(V): U $callback + * @return Box + */ + public static function mapStatic(mixed $value, callable $callback): Box + { + return new Box($callback($value)); + } +} + +/** + * @template T + * @param callable(): T $callback + * @return T + */ +function run(callable $callback): mixed +{ + return $callback(); +} + +function boxThreeLevels(): void +{ + $box = new Box(1); + assertType('Bug15406\Box>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): Box => (new Box($j))->map(fn (int $k): array => ['k' => $k])))); +} + +function boxStaticThreeLevels(): void +{ + assertType('Bug15406\Box>>', Box::mapStatic(1, fn (int $i): Box => Box::mapStatic($i, fn (int $j): Box => Box::mapStatic($j, fn (int $k): array => ['k' => $k])))); +} + +function boxTwoLevels(): void +{ + /** @var Box $box */ + $box = new Box(1); + assertType('Bug15406\Box>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j]))); +} + +run(function (): void { + $box = new Box(1); + assertType('Bug15406\Box>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j]))); + + /** @var Box $box */ + $box = new Box(1); + assertType('Bug15406\Box>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j]))); +}); + +function arrayMapFourLevels(): void +{ + 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])); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 67b5837fe6d..12b26b000b7 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3350,6 +3350,14 @@ public function testBug15432(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testBug15406(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-15406.php'], []); + } + public function testBug15487(): void { $this->checkExplicitMixed = true; diff --git a/tests/PHPStan/Rules/Functions/data/bug-15406.php b/tests/PHPStan/Rules/Functions/data/bug-15406.php new file mode 100644 index 00000000000..925b5d40318 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-15406.php @@ -0,0 +1,66 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15406Rule; + +final class Node +{ + /** @param list $children */ + public function __construct(public readonly array $children, public readonly int $value) {} +} + +/** + * @template T + * @template U + * + * @param list $list + * @param callable(T): iterable $fn + * + * @return list + */ +function collect(array $list, callable $fn): array +{ + $result = []; + foreach ($list as $value) { + foreach ($fn($value) as $item) { + $result[] = $item; + } + } + + return $result; +} + +/** + * @param list $nodes + */ +function twoLevels(array $nodes): void +{ + $result = collect($nodes, static fn (Node $node): array + => collect($node->children, static fn (Node $child): array => [$child->value]), + ); +} + +/** + * @param list $nodes + */ +function threeLevels(array $nodes): void +{ + $result = collect($nodes, static fn (Node $node): array + => collect($node->children, static fn (Node $child): array + => collect($child->children, static fn (Node $grandChild): array => [$grandChild->value]), + ), + ); +} + +/** + * @param list $nodes + */ +function threeLevelsGenerators(array $nodes): void +{ + $result = collect($nodes, static fn (Node $node): iterable + => yield from collect($node->children, static fn (Node $child): iterable + => yield from collect($child->children, static fn (Node $grandChild): iterable => yield $grandChild->value), + ), + ); +} diff --git a/turbo-ext/src/ClosureTypeResolver.cpp b/turbo-ext/src/ClosureTypeResolver.cpp index b1127d440d1..0134085444c 100644 --- a/turbo-ext/src/ClosureTypeResolver.cpp +++ b/turbo-ext/src/ClosureTypeResolver.cpp @@ -7,7 +7,8 @@ * cache lives in the twin's $cachedTypes slot with the twin's shape * (spl_object_id => ['expr' => ..., 'types' => [cacheKey => [...]]]) and is * updated in place. The private static $resolveClosureTypeDepth guard is a - * C++ counter (the declared static stays for reflection parity). + * C++ list of the closure nodes being walked (the declared static stays for + * reflection parity). * * getClosureType(), buildClosureTypeForClosure(), * buildClosureTypeForArrowFunction() and getDeclaredClosureType() are exported @@ -36,6 +37,8 @@ namespace sigs = ptdecl::ClosureTypeResolver::sig; #include "zend_smart_str.h" +#include + zend_class_entry *pt_ce_closure_type_resolver = nullptr; namespace { @@ -55,8 +58,30 @@ zend_string *pt_ctr_impure_points = nullptr; zend_string *pt_ctr_invalidate_expressions = nullptr; zend_string *pt_ctr_used_variables = nullptr; -/* the private static $resolveClosureTypeDepth */ -int pt_ctr_resolve_closure_type_depth = 0; +/* the private static $resolveClosureTypeDepth: the object handles of the + * closure nodes whose body walk is in progress, innermost last - a node's + * depth is the number of its entries */ +std::vector pt_ctr_resolve_closure_type_walks; + +int resolveClosureTypeDepth(uint32_t exprId) +{ + int depth = 0; + for (uint32_t id : pt_ctr_resolve_closure_type_walks) { + if (id == exprId) depth++; + } + return depth; +} + +void decrementResolveClosureTypeDepth(uint32_t exprId) +{ + for (auto it = pt_ctr_resolve_closure_type_walks.end(); it != pt_ctr_resolve_closure_type_walks.begin();) { + --it; + if (*it == exprId) { + pt_ctr_resolve_closure_type_walks.erase(it); + return; + } + } +} /* {{{ the PHP collaborators (one site each; switch to their direct entries * once they are ported) */ @@ -355,7 +380,7 @@ class ClosureTypeResolver ZVAL_DEREF(cached); return createClosureTypeFromCache(scope, expr, p.parameters.raw(), p.isVariadic, cached, p.contextFree); } - if (pt_ctr_resolve_closure_type_depth >= 2) return signatureClosureType(scope, expr, p.parameters.raw(), p.isVariadic); + if (resolveClosureTypeDepth(Z_OBJ_HANDLE_P(expr)) >= 2) return signatureClosureType(scope, expr, p.parameters.raw(), p.isVariadic); if (UNEXPECTED(!countClosureTypeBodyWalk())) return zv::Val(); int isArrowFunction = ptclosure::instanceOf(expr, PT_CLASS_ARROW_FUNCTION); @@ -1353,7 +1378,7 @@ class ClosureTypeResolver zv::Val arrowFunctionImpurePoints = ptsh::newArrayReference(); zv::Val invalidateExpressions = ptsh::newArrayReference(); - pt_ctr_resolve_closure_type_depth++; + pt_ctr_resolve_closure_type_walks.push_back(Z_OBJ_HANDLE_P(expr)); zv::Val walkStorage = pt_expression_result_storage_new(); if (UNEXPECTED(walkStorage.isUndef())) return zv::Val(); if (UNEXPECTED(!pt_mutating_scope_push_expression_result_storage(Z_OBJ_P(arrowScope.raw()), walkStorage.raw()))) return zv::Val(); @@ -1374,7 +1399,7 @@ class ClosureTypeResolver } pt_finally([&]() { (void) pt_mutating_scope_pop_expression_result_storage(Z_OBJ_P(arrowScope.raw())); - pt_ctr_resolve_closure_type_depth--; + decrementResolveClosureTypeDepth(Z_OBJ_HANDLE_P(expr)); }); if (UNEXPECTED(exprResult.isUndef() || EG(exception) != NULL)) return zv::Val(); @@ -1400,7 +1425,7 @@ class ClosureTypeResolver /* the closure body walk of getClosureType() */ zv::Val walkClosure(zval *scope, zval *expr, Parameters &p, zend_string *cacheKey) { - pt_ctr_resolve_closure_type_depth++; + pt_ctr_resolve_closure_type_walks.push_back(Z_OBJ_HANDLE_P(expr)); zval null; ZVAL_NULL(&null); @@ -1448,7 +1473,7 @@ class ClosureTypeResolver } pt_finally([&]() { (void) pt_mutating_scope_pop_expression_result_storage(Z_OBJ_P(closureScope.raw())); - pt_ctr_resolve_closure_type_depth--; + decrementResolveClosureTypeDepth(Z_OBJ_HANDLE_P(expr)); }); if (UNEXPECTED(closureStatementResult.isUndef() || EG(exception) != NULL)) return zv::Val(); diff --git a/turbo-ext/src/generated/ClosureTypeResolver.h b/turbo-ext/src/generated/ClosureTypeResolver.h index e1bfec6f1e4..05976aff920 100644 --- a/turbo-ext/src/generated/ClosureTypeResolver.h +++ b/turbo-ext/src/generated/ClosureTypeResolver.h @@ -26,7 +26,7 @@ inline void declareClass(reg::Class &cls) /* the properties the class declares itself, in declaration order (a used trait's come from its registrar) */ inline void declareProperties(reg::Class &cls) { - cls.property("resolveClosureTypeDepth", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedLong, 0); + cls.property("resolveClosureTypeDepth", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedEmptyArray, MAY_BE_ARRAY); cls.property("cachedTypes", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedEmptyArray, MAY_BE_ARRAY); cls.property("nodeScopeResolver", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, 0, "PHPStan\\Analyser\\NodeScopeResolver"); cls.property("initializerExprTypeResolver", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, 0, "PHPStan\\Reflection\\InitializerExprTypeResolver");