Repository navigation
Track ClosureTypeResolver's body-walk depth guard per closure node instead of globally - #6807
Open
phpstan-bot wants to merge 1 commit into
Open
phpstan-bot wants to merge 1 commit into
phpstan-bot wants to merge 1 commit into
Conversation
…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>
ondrejmirtes
force-pushed
the
create-pull-request/patch-37ymnbo
branch
from
October 27, 2026 18:45
7085ec7 to
bcfd2f1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A template inferred from a callable parameter's return type (e.g.
Uincallable(T): iterable<U>) resolved tomixedwhen generic calls were nested three closures deep (or two, with a@var-annotated receiver inside a closure argument). The outer argument was then rejected against itself:expects callable(Node): iterable<int>, Closure(Node): list given. This was a 2.3.0 regression. The fix makes the closure body-walk recursion guard count per closure node instead of globally.Changes
src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php:$resolveClosureTypeDepthchanges from a globalintto anarray<int, int>keyed byspl_object_id()of the closure / arrow-function node. The>= 2check, the increments and the decrements (in both the arrow-function and closure branches) use the per-node entry. The entry is unset when it drops back to zero.turbo-ext/src/ClosureTypeResolver.cpp: same change in the native mirror. The C++ counter becomes astd::vectorof object handles of the nodes being walked, and a node's depth is the number of its entries.turbo-ext/src/generated/ClosureTypeResolver.h: regenerated property declaration (TypedLong→TypedEmptyArray).Cases verified by the new tests:
function () {}closures, with and without declared return types, and generators (yield from)Box::map()) and static methods@var-annotated receiver inside a closure passed as an argument (the variant from the comments)array_map()with untyped arrow functions, which inferredarray{array{array{mixed}}}before and now gives the full shapeProbed and unchanged: self-referencing and mutually recursive by-ref closures (
use (&$f)) still terminate and give the same results as before.Root cause
Before a closure argument is walked for real,
ArgumentsHandler::gatherClosureArgType()pre-walks its body throughClosureTypeResolver::getClosureType()to infer the callable's templates. When that body contains another generic call with a closure argument, these pre-walks nest, one per level.getClosureType()stopped walking once the static$resolveClosureTypeDepthreached 2. That guard is meant for a self-by-ref closure re-walking itself, but the counter was global, so it also cut off unrelated nested closures. The innermost closure then fell back to its declared signature (Closure(Node): array), soUbecamemixed. Counting per node keeps the self-recursion protection without limiting how deeply different closures can nest. The per-closure type cache absorbs the repeated walks.Test
tests/PHPStan/Analyser/nsrt/bug-15406.php:assertType()for the reported snippet (two and three levels, generators), closures with and without return types, five levels,Box::map()and static-method nesting, the@varvariants from the comments, and nestedarray_map().tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest::testBug15406()(explicit and implicit mixed checks on, like level max): noargument.typeerror for the reported snippet.Both tests fail without the fix and pass with it.
make testspasses with and without the turbo extension loaded, and so doesmake phpstan.turbo-extside-by-side method parity, signature parity and the smoke test pass forClosureTypeResolver.Fixes phpstan/phpstan#15406
🤖 Generated with Claude Code