Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857
Open
ondrejmirtes wants to merge 71 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 71 commits into
ondrejmirtes wants to merge 71 commits into
Conversation
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
3 times, most recently
from
June 12, 2026 17:15
ebb19a0 to
457689b
Compare
staabm
reviewed
Jun 12, 2026
| return $this->withFlavor(false); | ||
| } | ||
|
|
||
| private function withFlavor(bool $fiber): self |
Contributor
There was a problem hiding this comment.
should this read withFiber?
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
2 times, most recently
from
June 19, 2026 11:44
eb31077 to
59cbf22
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
June 20, 2026 11:56
59cbf22 to
125cf22
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
4 times, most recently
from
July 6, 2026 22:20
f98892f to
4455baa
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
July 16, 2026 14:56
61fe06e to
e38aadd
Compare
ondrejmirtes
referenced
this pull request
Jul 23, 2026
Every property fetch / method call resolves its type by walking down to the chain root to detect a nullsafe operator (NullsafeShortCircuitingHelper), costing O(N²) walk steps per chain of depth N — with or without an actual nullsafe operator in the chain. Deep loop-wrapped plain chains make that walk dominate: 3.71s -> 3.14s wall (-15%), -18% user CPU from the recursion-to-loop rewrite. The real-world counterpart is Symfony TreeBuilder fluent chains (300+ calls in one statement) in Sylius bundle Configuration classes, which dropped up to 23% per file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016szvNF5RXhACdfMQNc6DVL
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
12 times, most recently
from
July 28, 2026 17:31
fb22d34 to
84b1614
Compare
Rules and collectors re-ask the same nodes across a callback batch, and the walk scope's resolvedTypes memo used to answer those repeats in O(1) before the callback-facing scope existed. Every repeat paid the stored-result guard - variable-state compares, node-key printing on re-priced asks - which shipmonk's rule set (disallowed-calls formatting every call, the dead-code collectors) multiplied into whole test-file windows running twice as slow as 2.2.x. The entry pins the asked node: a dropped synthetic's object id can be reused by the next synthetic, and the identity check rejects the stale hit.
…ults Engine code (NodeScopeResolver, StmtHandlers, ExprHandlers) no longer prices expressions through Scope::getType()/getNativeType() - every walked node's type is read from its stored ExpressionResult: - ArgsResult::requireArgResult() reads a call argument's result non-optionally; closure arguments now land in the ArgsResult map too (the default closure-arg branch stored the refined result but never captured it). - FuncCallHandler's array-function special cases (array_walk, array_pop/shift, shuffle, sort family, array_splice, extract, array_push/unshift appending) read argument results, priced on the post-args scope where the original read did. The dynamic-callee type comes from the callee's stored result. - Closure::bind()'s bound-this/scope arguments read their stored results (processArgs orders closures last, so they are stored by the time the bind scope factory runs); degenerate code prices as mixed instead of walking. - ClosureTypeResolver reads gathered return/yield expressions and array_map argument types from the walk's storage, threaded through getClosureType()/buildClosureTypeForClosure()/buildClosureTypeForArrowFunction(); previously the assembly re-walked every return expression because the body walk's storage was discarded. The scope read remains only for rule-facing bridge asks (no storage) and immediately-invoked closures (whose invocation arguments are walked after the closure itself). - OutputBufferHelper reads the tracked ob_get_level() holder through readScopeStateOrSyntheticType() instead of pricing the synthetic call. The new NoScopeTypeReadInEngineRule build rule enforces the ban, with the documented seams allowlisted.
The never-returning-constructor check and the parent-constructor template resolution both walk synthetic nodes the handler built; route them through NodeScopeResolver::processSyntheticOnDemand() instead of Scope::getType(), and drop NewHandler from the build rule's seam allowlist.
The scope-only side effects of a call - by-ref array function results (array_pop(), sort(), array_splice(), ...), paired-read invalidation (json_last_error(), file_get_contents()), possibly-impure value remembering, output-buffer level tracking and volatile-expression invalidation - move out of FuncCallHandler::processExpr() into a dedicated helper. Nothing in the moved chain touches the call's own result state. processExpr() runs for every function call and its frame is paid on every recursion level; the extraction shrinks it from 1,536 to 960 bytes (91 -> 55 compiled variables) and FuncCallHandler from 1,338 to ~900 lines.
…sults are stored processArgs fired the Closure/ArrowFunction argument's own node callback before the body walk, so callback-side consumers (DependencyResolver, rules listening on closure nodes) asking getType() re-walked the still-unstored node. Firing the callback after storeExpressionResult() mirrors processExprNodeInternal(): the answer comes from the stored ExpressionResult.
…essed The dimensions loop in doPrepareTarget() fired an intermediate chain link's node callback before evaluating its dimension and before the link's write-flavoured result existed, so callback-side consumers (NonexistentOffsetInArrayDimFetchRule, DependencyResolver) re-walked the yet-unstored sub-expressions. The callback now fires at the end of the iteration, with the link's entry scope, after its result is stored.
applyWrite() emits PropertyAssignNode, whose rules ask about the whole `$lvalue OP= value` expression - without a stored result they re-walked the node mid-processing. The composed result is stored before applyWrite() and overwritten with the final result after the handler returns.
InvalidKeyInArrayItemRule asks about the item's key at the ArrayItem callback - firing the callback after the key and value walks lets it answer from the storage instead of re-walking them.
…their expressions These statements' rules (UnsetRule, ValueAssignedToClassConstantRule, ValueAssignedToGlobalConstantRule, WhileLoopAlwaysFalseConditionRule) read the statement's child expressions, so the statement callback now fires inside the handler after those expressions are processed - with the entry scope, joining the existing deferred set (Return_, Expression, Echo_, If_, Switch_, Foreach_). The per-constant Const node callbacks move after their value walks for the same reason.
…eScopeStateType The tracked-expression branch mirrors resolveType()'s holder lookup directly (with late-resolvable types resolved, deliberately skipping the extension hook like the getVariableType() read), and constant expressions (scalars, self::KEY, bare constants) price through InitializerExprTypeResolver - the isset()/??/empty ensure reads no longer walk unprocessed nodes through getType().
…ityRule The rule fires on InClassNode before the enum body is walked; case values are initializer expressions, so the initializer resolver (already used by the not-backed-enum message) is the right source - not Scope::getType() on the yet-unprocessed node.
inferAndCachePropertyTypes() builds its own scope and prices constructor assignment values - a reflection-level pass independent of the file's main walk. The new NodeScopeResolver::processIndependentPassExpr() processes such expressions on a fresh storage instead of routing through Scope::getType().
ClassAttributesRule fires on InClassNode and reads the attribute constructor arguments - walking the attribute groups first lets it answer from the storage.
The Set*OffsetValueTypeExpr nesting substituted a property base's holder type while nothing of the target was walked yet, re-pricing the fetch on demand. Building the chain after the root walk reads the property base and its current type from the stored result.
SimpleImpurePoint::createFromVariant() reads an argument's type for pure-unless-callable-is-impure parameters - moved past processArgs() in MethodCallHandler and StaticCallHandler, mirroring FuncCallHandler.
enterAnonymousFunction() priced the use variable's native flavour through getNativeType() on the node; the by-name read on the natively-promoted scope is the same state without walking the possibly-unprocessed node.
The closure is the callee, so its invocation arguments are walked after it - constant arguments price through InitializerExprTypeResolver and plain variables read scope state by name; only the remaining shapes keep the ask-ahead-of-walk scope read.
SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict() reads an argument's type for pure-unless-callable-is-impure parameters - the impure-point computation moves out of processConstructorReflection() past processArgs(), like the other call handlers.
…fiedTypes() The old-world appliers (addTypeToExpression()/removeTypeFromExpression()) skip narrowing a union past COMPLEX_UNION_TYPE_MEMBER_LIMIT members with HasOffsetValueType intersections - the circuit breaker for combinatorial array|object offset-access growth. applySpecifiedTypes() only consulted it on the untracked-expression path, so isset()-style checks on a tracked variable kept doubling its union (tests/bench/data/wordpress-user.php: 64.5s -> 1.4s, output identical to 2.2.x again).
The rule-facing gate compared each read variable via isSuperTypeOf() - O(keys^2) on large constant arrays, once per callback-side ask. Type identity and equals() (O(keys)) answer the overwhelmingly common unchanged-variable case first; the engine-facing equals() gets the same identity short-circuit. tests/bench/data: bug-5081 4.3s -> 2.0s, bug-11913 4.0s -> 1.3s, bug-8503/bug-10979/nullsafe-chain-walk back at or below the 2.2.x baseline.
The branch's own fixpoint-replay commits were dropped during the rebase over the merged extraction chunks (#6249); this restores the replay in upstream's final form - raw-recorded pairs wrapped at replay time, the RecordingNodeCallback short-circuit in callNodeCallback - woven into the branch's handler shapes (ambient storage push around pass walks, the deferred While_ statement callback, the on-demand falsey cond re-pricing). replayRecording() binds the storage through the scope's push/pop like every other branch-side ambient binding. The consume mechanism (#6251, closed unmerged) is gone entirely: convergence behavior on this branch is now byte-for-byte upstream's algorithm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
The rebase over the merged gathering cleanup (#6258) kept the branch's wrapper-based gatherer sites while the base deleted GatheringNodeCallback; this converts them to the upstream frame mechanism - pushNodeGatherer()/popNodeGatherer() on NodeScopeResolver, fed the raw walk scope by callNodeCallback() and per replayed pair by replayRecording() - woven into the branch's shapes: the per-body storages of method/function bodies, the ambient storage pushes, and the scope-bound replayRecording() signature. Gatherer bodies are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
August 24, 2026 21:15
d9a567a to
f6fa312
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.
Groundwork for the "new world" where an expression is traversed once: after
processExpr, itsExpressionResultknows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementingTypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, theTypeSpecifierdispatcher) are guarded behindNewWorld::disableOldWorld()and get mass-deleted in PHPStan 3.0.What's on the branch, bottom up:
ExpressionResultFactory: old-world type resolution entry points throw whenNewWorld::disableOldWorld()is flipped (the migration meter); allExpressionResultconstruction goes through a generated factory.ExpressionResultcarriesbeforeScope,expr,typeCallback,specifyTypesCallbackand is stored per node inExpressionResultStorage(layered O(1)duplicate()), replacing the stored before-Scope.ExprHandler/TypeResolvingExprHandlersplit:resolveType/specifyTypesmove to the sub-interface so handlers can shed them one by one.ExpressionResultStorageStack: old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers'resolveType) keep working for converted handlers' nodes. Every scope shares the stack created by its internal scope factory;NodeScopeResolverpushes the storage of the analysis in progress throughMutatingScope::pushExpressionResultStorage()(always popped infinally, throwing on imbalance), andMutatingScopeanswers from the stored result - or processes a synthetic node on demand. Scopes never reference a storage directly, so nothing pins the result graph with the cycle collector disabled inbin/phpstan. Also addsMutatingScope::applySpecifiedTypes-filterBySpecifiedTypeswithoutScope::getType().ScalarHandlerandArrayHandlerno longer implementTypeResolvingExprHandler. The array migration is a precision win the old world cannot reach: each item type is captured at its own evaluation point, so[$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c]infersarray{1, 2, 1, 3, 1, 2}.Verified: full test suite green,
make phpstanclean, and analysis memory back at baseline (no leak from the result graph despitegc_disable()).Closes phpstan/phpstan#13944
Closes phpstan/phpstan#12207
Closes phpstan/phpstan#7155
Closes phpstan/phpstan#14396
Closes phpstan/phpstan#11953
Closes phpstan/phpstan#12780
🤖 Generated with Claude Code
Closes phpstan/phpstan#14999
Closes phpstan/phpstan#13334
Closes phpstan/phpstan#15004