Skip to content

Build closure and arrow function types from the single rule body walk - #6169

Open
ondrejmirtes wants to merge 5 commits into
2.2.xfrom
closure-body-single-walk
Open

Build closure and arrow function types from the single rule body walk#6169
ondrejmirtes wants to merge 5 commits into
2.2.xfrom
closure-body-single-walk

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

A closure or arrow function passed through the analyser had its body walked several times: once by processClosureNode()/processArrowFunctionNode() for the rules, and again by every getClosureType() pricing ask — the lazy handler resolveType(), the invalidate-expression reads in processArgs(), and the native-flavour ask each re-walked unless a whole-scope cache key happened to collide. This PR makes the pricing asks answer from the rule walk:

  • ProcessClosureResult carries the gathered return/yield statement-scope pairs, execution ends, and impure points (pre-merged in getClosureType() order); the new ProcessArrowFunctionResult carries the walked arrow scope and the type-relevant points.
  • ClosureTypeResolver gains buildClosureTypeForClosure()/buildClosureTypeForArrowFunction(), which construct the same ClosureType from the gathered data — reading the return/yield types off the gathered scopes, natively via the promoted flavour when asked — and getClosureType() now shares their whole tail (parameter building with defaults priced through InitializerExprTypeResolver, the never/void derivation, Generator shape, assembly + cache write). array_map() callbacks and immediately invoked closures keep walking in getClosureType() because their parameters are typed from the call site.
  • The handlers and the processArgs() argument branches seed the per-node type cache from the walk they just performed. The cache key becomes parameter-aware, and flavour-separated for arrow functions (whose native return type genuinely differs); a closure's native type equals its phpdoc type, so its native-flavour slot is seeded with the same build — making that equality deliberate where it previously depended on a scope-hash collision.
  • Fiber-completeness contract preserved: the gathered arrays fill in by reference and are complete only once pending fibers flush (#14888's fix reads the invalidate expressions off the flushed ClosureType for exactly this reason). All seeding is gated on the storage having no parked fibers, and the two protected reads in processArgs() are textually untouched — they become cache hits in the common case and keep re-walking flushed when anything is parked.

Zero expectation churn: full test suite green in both turbo modes (17844 tests), self-analysis and CS clean. No turbo-shadowed class is touched, so no extension bump.

Follow-up (separate PR): shallow closure/arrow scope entry (enterAnonymousFunction() currently re-walks the body to build the reflection) with the refined-type swap for ClosureReturnStatementsNode/InArrowFunctionNode, and the free-variable-slice cache key that makes seed hits robust against unrelated scope changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7

ondrejmirtes and others added 5 commits July 31, 2026 22:28
…sult

processClosureNode() already sees every return, yield and execution end of the
closure body on the scope they occur on. Record the statement-scope pairs and
carry them - together with the impure points pre-merged in getClosureType()
order - on ProcessClosureResult, so a closure type can later be built from the
single walk instead of walking the body again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
buildClosureTypeForClosure()/buildClosureTypeForArrowFunction() construct a
ClosureType from the return/yield statements and execution ends a prior body
walk already gathered (reading their types off the gathered scopes, natively
via the promoted flavour when asked), without walking the body again.
getClosureType() - the walking resolver - now shares their whole tail:
parameter building (defaults priced through InitializerExprTypeResolver),
the never/void execution-end derivation, the Generator return shape, and the
final assembly with its cache write. array_map() callbacks and immediately
invoked closures keep re-walking (their parameters are typed from the call
site, so a foreign walk's gathered scopes would answer wrongly).

No caller feeds the builders yet; cache keys and cache participation are
unchanged (arrows still price fresh on every ask).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
ClosureHandler::processExpr() walks the closure body for the rules; the lazy
getClosureType() pricing then walked it again. The handler now seeds the
per-node type cache from the gathered walk data, so the pricing asks answer
without a second walk. The cache key becomes flavour- and parameter-aware
(closureContextCacheKey), and the native-flavour slot is seeded with the same
build: a closure's native type equals its phpdoc type, which until now only
held when the promoted scope's holder hash happened to collide with the plain
one - the pin is now deliberate and collision-independent. array_map()
callbacks and immediately invoked closures seed through a getClosureType()
walk with their call-site parameter types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
processArrowFunctionNode() now returns ProcessArrowFunctionResult: the walked
arrow scope plus the throw points, impure points (merged in getClosureType()
order) and invalidate expressions its type needs, gathered during the single
body walk. ArrowFunctionHandler seeds the per-node type cache with both
flavours built from the walk - unlike a closure, an arrow function's native
return type genuinely differs from its phpdoc one, so the native slot gets its
own build reading the walked body's native types. Arrow functions now
participate in the cache read: the flavour- and parameter-aware key answers
each ask from its own seeded slot.

Both seeding paths (this one and the closure one) are gated on the storage
having no parked fibers: a parked fiber may still append to the walk's
gathered data - the invalidate expressions of a write like $this->prop[] = ...
arrive only when the fiber flushes - so a seeded entry could be incomplete.
When anything is parked, the lazy ask keeps re-walking with the fibers
flushed, as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
processArgs() walks closure and arrow function arguments itself; the reads
that follow - the preferred ClosureType invalidate-expression read for
closures and the invalidation read for arrow functions - then priced the
argument through another body walk. Seeding the type cache from the walk just
performed makes those reads cache hits. The reads themselves are untouched:
when a parked fiber may still complete the gathered data, the seed is skipped
and they keep re-walking with the fibers flushed, preserving the completeness
contract documented at the closure read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
@ondrejmirtes

Copy link
Copy Markdown
Member Author

Benchmark: commit-toggled ABBA, same tree, no-turbo, bin/phpstan analyse src/Analyser src/Rules with cleared result cache, user CPU, 6 pairs (machine at load ~6, moderately noisy):

  • base (2.2.x): median 51.00s — [47.31, 49.00, 50.97, 51.03, 51.44, 54.80]
  • this PR: median 50.08s — [49.58, 49.73, 49.89, 50.27, 52.12, 52.90]
  • delta −1.80% median; paired mean diff −0.01s (sd 1.63s)

Directionally a win with a visibly tighter spread on the PR side, but the pairing is washed out by machine noise at this n — I can rerun a longer series on a quiet machine if you want a firmer number. The structural claim (pricing asks hit the seeded cache instead of re-walking) is independent of the benchmark.

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.

1 participant