Skip to content

Track ClosureTypeResolver's body-walk depth guard per closure node instead of globally - #6807

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-37ymnbo
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-37ymnbo

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A template inferred from a callable parameter's return type (e.g. U in callable(T): iterable<U>) resolved to mixed when 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: $resolveClosureTypeDepth changes from a global int to an array<int, int> keyed by spl_object_id() of the closure / arrow-function node. The >= 2 check, 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 a std::vector of 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:

  • arrow functions (the reported case) and function () {} closures, with and without declared return types, and generators (yield from)
  • five levels of nesting
  • templates on instance methods (Box::map()) and static methods
  • a @var-annotated receiver inside a closure passed as an argument (the variant from the comments)
  • nested array_map() with untyped arrow functions, which inferred array{array{array{mixed}}} before and now gives the full shape

Probed 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 through ClosureTypeResolver::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 $resolveClosureTypeDepth reached 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), so U became mixed. 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 @var variants from the comments, and nested array_map().
  • tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest::testBug15406() (explicit and implicit mixed checks on, like level max): no argument.type error for the reported snippet.

Both tests fail without the fix and pass with it. make tests passes with and without the turbo extension loaded, and so does make phpstan. turbo-ext side-by-side method parity, signature parity and the smoke test pass for ClosureTypeResolver.

Fixes phpstan/phpstan#15406

🤖 Generated with Claude Code

…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
ondrejmirtes force-pushed the create-pull-request/patch-37ymnbo branch from 7085ec7 to bcfd2f1 Compare October 27, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Template from a callable's iterable return type resolves to mixed three closure levels deep (regression in 2.3.0)

1 participant