Skip to content

Use the enclosing body's send-decided template argument resolutions inside nested closure bodies - #6819

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

With bleeding edge (unresolvedTemplateArguments), a generic call inside a closure that has a statement body kept its literal template arguments. The declared return type of the enclosing method did not widen them, while the same code in an arrow function worked. For example, keyBy() inferred 'negative'|'positive' where Coll<string, int> was expected. When the closure was called through a variable, the internal unresolved(...) marker even showed up in the error message.

The enclosing body already resolves those sites correctly, because its observation pass sees the send to @return Coll<string, int>. The nested closure body ignored that resolution and solved the site again with only its own information. This PR makes the nested body reuse a resolution from an enclosing body when a send decided it.

Changes

  • src/Analyser/Generics/TemplateArgumentFrame.php: new sentResolutionKeys and resolveSentFromEnclosingBody(). It returns the resolution of the nearest frame (walking up the parents) where a send decided the argument.
  • src/Analyser/Generics/TemplateArgumentResolver.php: collectSentResolutionKeys() passes the keys of non-closure-signature observations with sends to both frames that resolve() builds. withObservedClosures() and withStaticVariableTypes() carry them along.
  • src/Reflection/ResolvedFunctionVariantWithOriginal.php (unresolvedOrResolvedTemplateArgument()): while observing, uses the enclosing send-decided resolution before creating a marker. This covers method, function and static calls.
  • src/Analyser/Traverser/GenericTypeTemplateTraverser.php, src/Analyser/ExprHandler/NewHandler.php (unresolvedArgumentList(), rekeyParentTemplateArgument()): the same for new sites. That includes constructor-inferred arguments, classes without a constructor, and arguments inherited from a parent constructor.
  • src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php (getClosureType()): while the enclosing frame is observing, the closure body is walked in a single pass. Its markers then reach the enclosing body, for example through the return type of run(function () { ... }). Before, the closure's own two-pass resolution consumed them first.
  • Turbo mirrors are updated in the same change:
    • turbo-ext/src/TemplateArgumentFrame.cpp
    • turbo-ext/src/TemplateArgumentResolver.cpp
    • turbo-ext/src/ResolvedFunctionVariantWithOriginal.cpp
    • turbo-ext/src/NewHandler.cpp
    • turbo-ext/src/ClosureTypeResolver.cpp
    • turbo-ext/src/support.h
    • regenerated turbo-ext/src/generated/TemplateArgumentFrame.h and TemplateArgumentResolver.h

Analogous cases fixed, each covered by the rule test:

  • static function closures
  • nested block closures
  • block closures inside arrow functions
  • immediately invoked closures
  • closures stored in a variable
  • new with constructor-inferred arguments
  • function calls (collect([...]))
  • static calls (Coll::make([...]))
  • new of a class without a constructor
  • new of a child class that inherits a generic parent constructor (block closure and closure variable)

Probed and already correct (no change needed): arrow functions, which are part of the enclosing body, and closures stored in variables inside other closures.

Root cause

Template argument resolution runs once per function-like body (StatementsHandler::processBodyStmtNodesTwoPass()). A closure body has its own frame, while an arrow function is part of the enclosing body. Inside a closure's observation pass, every generic call or new created a new UnresolvedTemplateArgumentType marker. With only a raw native return type (: Coll) and no send inside the closure, it resolved to the literal initial type. Two paths were affected:

  1. Closure called through a variable: in the enclosing body's second pass, the closure body is walked again with a fresh observing frame. The enclosing body's correct resolution (string) was ignored, so the closure created a new marker.
  2. Closure passed to a generic call: ClosureTypeResolver::getClosureType() walked the body with template argument resolution enabled even during the enclosing observation pass. The closure's return type reached run()'s T already resolved to the literal, so the enclosing body never saw a marker it could resolve.

A first version that reused any enclosing resolution regressed closure-signature-from-usages-generics.php. There the enclosing body infers new Box($v) only from the closure parameter (no send), and the closure's own per-invocation walk knows better (Box<1>). So only resolutions that a send decided are reused.

Test

  • tests/PHPStan/Rules/Methods/data/bug-15415.php with ReturnTypeRuleTest::testBug15415() covers:

    • both playground samples from the issue (blockClosure, closureInVariable), plus arrowFunction and direct as controls
    • the analogous cases listed above
    • a mapWithKeys()-style callback with a body

    Before the fix it reported 11 false return.type errors (and argument.type errors in the nested cases), including Coll<unresolved('negative'|'positive'), int>.

  • tests/PHPStan/Analyser/nsrt/bug-15415.php asserts Coll<string, int> inside the closure body, for the result of run(), and for $f(). All three assertions failed before.

  • Full test suite passes with and without the turbo extension. make phpstan passes. Turbo smoke and signature-parity tests pass. Analysis output is identical with the extension on and off for the reproducers.

Fixes phpstan/phpstan#15415

🤖 Generated with Claude Code

…nside nested closure bodies

- TemplateArgumentFrame: record which resolutions a send decided (sentResolutionKeys,
  filled by TemplateArgumentResolver::collectSentResolutionKeys()) and expose them via
  resolveSentFromEnclosingBody(), which walks up the parent frames
- ResolvedFunctionVariantWithOriginal::unresolvedOrResolvedTemplateArgument(): while a
  closure body observes, reuse an enclosing body's send-decided resolution of the call
  site instead of creating a fresh marker that the closure's own pass resolves to the
  literal initial type (method, function and static calls)
- NewHandler (unresolvedArgumentList(), rekeyParentTemplateArgument()) and
  GenericTypeTemplateTraverser: same for `new` sites - constructor-inferred arguments,
  classes without a constructor, and arguments inherited from a parent constructor
- ClosureTypeResolver::getClosureType(): walk a closure body in a single pass while the
  enclosing body is observing, so its markers reach the enclosing body (the generic call
  taking the closure, e.g. run(function () { ... })) instead of being resolved by the
  closure's own pass before the enclosing body could see the send that decides them
- Only send-decided resolutions are reused: an argument the enclosing body merely
  inferred (e.g. from a closure parameter, closureCreatesGenericObject) stays with the
  closure's own walk, which infers it per invocation
- Ported to the turbo extension: TemplateArgumentFrame.cpp, TemplateArgumentResolver.cpp,
  ResolvedFunctionVariantWithOriginal.cpp, NewHandler.cpp, ClosureTypeResolver.cpp,
  support.h, regenerated TemplateArgumentFrame.h / TemplateArgumentResolver.h
- Probed and already correct: arrow functions (part of the enclosing body), nested
  closures inside arrow functions, closures stored in a variable inside a closure

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Declared return type does not widen literal template arguments inside a closure with a body (unresolvedTemplateArguments)

1 participant