Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857
Open
ondrejmirtes wants to merge 20 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 20 commits into
ondrejmirtes wants to merge 20 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
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
August 12, 2026 10:54
44a5b71 to
df01b1f
Compare
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
6 times, most recently
from
August 13, 2026 17:12
8f1031f to
1241c6a
Compare
getIdenticalResult() and getNotIdenticalResult() gain optional NodeScopeResolver and left/right Type parameters so inside-out narrowing callbacks can pass the operand types they already computed instead of having the helper re-price both sides through the scope. Rules keep calling the two-argument form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ExpressionResult becomes the single carrier of what a walked expression means: a per-flavour memoized typeCallback, a specifyTypesCallback memoized per (context, flavour), an optional createTypesCallback (the inside-out counterpart of TypeSpecifier::create()), and eager type/nativeType slots for handlers that already built both flavours. Truthy/falsey scopes are derived from the result's own specified types, with explicit overrides replacing the scope callbacks. Void projection moves here too: results keep the raw type and project void to null at the value-read boundary (getKeepVoidType() is the opt-out), replacing VoidToNullTypeTransformer and the keepVoid node attribute. Position awareness (getTypeOnScope(), answersOnScope(), askScopeVariableStateMatches(), takeReadVariableStateSnapshot()) lets consumers decide whether a stored result still answers on the asking scope. Test expectations follow the void change: a phpdoc @return void read as a value is now null. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ExpressionResultStorage now maps expressions to their full results, so a later consumer can read the type and narrowing of an already-processed node instead of re-walking it. duplicate() becomes O(1) through a read-only fallback chain, and mergeResults() unions only the storage's own entries (the trait-use path needs both). The new ExpressionResultStorageStack makes the storage of the analysis currently in progress reachable from any scope: both internal scope factories thread one shared stack instance into every MutatingScope they create, across the fiber/non-fiber boundary. The native ExpressionResultStorage twin mirrors the rework and the smoke test covers the fallback-chain semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
August 14, 2026 18:37
119ce6f to
98028f1
Compare
DefaultNarrowingHelper is the new-world counterpart of TypeSpecifier's default truthy/falsey handling, create()/createForExpr() and the assert/conditional-return specification: narrowing is composed from the already-walked subject's ExpressionResult (impure-call gate, plain-twin fan for chains containing nullsafe operators, isset chain entries) instead of re-probing the scope. CountNarrowingHelper receives the count()/sizeof() size specification that lived in TypeSpecifier. The helpers get their consumers as the handlers' resolveType() and specifyTypes() implementations move into result callbacks over the following commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
…rrowingHelper The equality narrowing (===, !==, ==, != and the specifying-function families driven by them) is rebuilt result-first: IdenticalNarrowingHelper composes the narrowing from the two operands' ExpressionResults, and specifyIdenticalAgainstType() serves callers that have no comparison node at all (assign-time conditional holders, switch cases, foreach exhaustiveness). BinaryOpHandler routes all four comparison operators through it with context negation instead of synthetic BooleanNot walks, and CastHandler narrows bool/int/double casts through a composed comparison against a fabricated literal. equality-narrowing-new-world.php pins the behaviour of every rewritten family; the class-name comparison fixtures cover ::class comparisons against unknown classes and the guard that a non-::class constant fetch does not narrow the object it is fetched on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
BooleanNarrowingHelper owns the && and || narrowing semantics parameterised over per-operand closures, so conjunctions and disjunctions without a real AST node (ternary decomposition, empty(), multi-subject isset, nullsafe receiver fans) reuse the same logic. The right side is walked once on the left-truthy scope and its result consumed, which deletes the flattening machinery and the BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH cap from BooleanAndHandler and BooleanOrHandler: deep chains now cost O(n), covered by the and-chain bench fixture. The disjunction augments and the conditional-expression holder helper stop asking the scope to re-price candidates and read scope state or the composed subject types instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
Mechanical conversion of the handlers with no structural rework: resolveType() moves into the result's typeCallback and specifyTypes() into its specifyTypesCallback (default narrowing or the empty callback), reading operand types from the already-walked child results. Lexical context that does not depend on the asking scope (initializer contexts, class and function reflections) is hoisted out of the callbacks; ArrayHandler keys per-item results by spl_object_id so each item resolves at its own evaluation point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
VirtualExprResultHelper builds walk-free ExpressionResults for TypeExpr, NativeTypeExpr and UnsetOffsetExpr, so fabricated and walked results have the same shape by construction. The offset virtual handlers now actually walk their sub-expressions and read the results, and the PossiblyImpureCall marker node gets a dedicated handler. The four FirstClassCallable*Handlers existed only to carry resolveType()/specifyTypes() for the *CallableNode virtual nodes; with those interface methods moving into callbacks, the CallableNode handlers own their type directly and the extra handlers are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
processArgs() captures every argument's ExpressionResult into ArgsResult together with the acceptor resolved after all arguments are walked, so the call handlers select the acceptor from argument results instead of pre-selecting it before the walk. FuncCall, MethodCall, StaticCall and New share the preliminary-result pattern: a result carrying the callbacks is stored before throw points are computed and finalize()d afterwards, because resolving the return type for throw points would otherwise recurse into the unfinished call. Dynamic return type extensions run inside a primed storage (DynamicReturnTypeStoragePrimer) so Scope::getType() on an argument inside an extension hits the stored result instead of re-walking the argument. MethodCallReturnTypeHelper accepts the pre-resolved acceptor and the ArgsResult; the implicit __toString and method throw point helpers take the caller's computed result and return type instead of re-pricing the receiver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ImpossibleCheckTypeHelper stops re-specifying the condition through TypeSpecifier: the three call virtual nodes carry the call's ExpressionResult, the rules read the narrowing verdict from it, and argument types come from the ArgsResult when available. The TypeSpecifier constructor dependency is gone, which also removes the argument from the 16 rule test constructors. TypeSpecifyingFunctionsDynamicReturnTypeExtension is deleted: the always-true/false collapse for array_key_exists()/key_exists()/ in_array()/is_subclass_of() lives in FuncCallHandler's typeCallback, reading its own stored result through a weak reference (a strong backedge would be an uncollectable cycle under gc_disable()). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
NullsafeShortCircuitingHelper's recursive chain walk is gone: expressions process inside-out, so only the nullsafe handlers ever see a ?-> link, and the other fetch and call handlers short-circuit through the operand result's containsNullsafe flag. The nullsafe handlers walk the receiver exactly once, consume the stored result for the plain twin, and compose the narrowing as receiver !== null && chain-truthy through the boolean helper, fanned through impure gates and default narrowing. NonNullabilityHelper keeps an explicit ensure stack so the handlers can recover the pre-device nullable receiver type, and resets it per file: an internal error escaping between an ensure and its revert must not leak a stale frame into the next file of the worker's batch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
PropertyFetch, StaticPropertyFetch, ArrayDimFetch and Variable move their type resolution into result callbacks over the walked child results. ArrayDimFetch resolves offsetGet through MethodCallReturnTypeHelper per flavour on a fabricated, never-walked MethodCall; dynamic $$name resolution composes name === '...' through IdenticalNarrowingHelper instead of filtering by a synthetic Identical walk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
The isset/empty/coalesce family stops re-walking its chains: the chain links' results are captured during the single walk, isset narrowing entries are built by DefaultNarrowingHelper from those results, empty($x) becomes an explicit !isset($x) || !$x disjunction through the boolean helper with IssetabilityResolution::notEmpty() supplying the type, and ?? composes both type and narrowing from the two sides' results per flavour (covered by the native-flavour fixture). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
TernaryHandler decomposes c ? a : b into (c && a) || (!c && b) through the boolean helpers with thunked branch scopes, and caches the three operand results per node for the assignment handler's conditional holders. MatchHandler narrows arm conditions through composed specifyIdentical() with a threaded per-arm subject state and unions the already-walked arm results; exhaustive matches over nullable enums no longer produce an UnhandledMatchError throw point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ClosureHandler and ArrowFunctionHandler build the closure type (both flavours) from the body walk the handler already performs and pass it eagerly - a lazy typeCallback would re-walk the body on every ask. ClosureTypeResolver keeps the resolved types in a per-file spl_object_id map instead of a node attribute (attributes would leak onto the parser cache's retained ASTs), keys closure scope caches by the closure's free variables, and exposes getClosureType() for scope entry without a body re-walk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
prepareTarget()/applyWrite() carry the walked results of the target chain and the assigned value on PreparedAssignTarget, so the write path never re-prices what the walk already computed: chain-link results are stored read-flavoured for parked rule asks, conditional-holder sentinel comparisons go through specifyIdenticalAgainstType(), and ??= composes through CoalesceCompositionHelper without a synthetic Coalesce walk. The inc/dec handlers share the string/numeric type ladder in IncDecTypeHelper and hand an explicit value result to the virtual assign. PropertyReflectionFinder gains a variant taking the already-known holder type so offset writes do not re-read the receiver, and the ExistingArrayDimFetch links now reference the original, already-processed nodes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
The engine switch-over. MutatingScope::getType() routes handler-backed nodes to the current storage's stored result and falls back to an on-demand walk for synthetic nodes; specifyTypesInCondition() delegates the same way, applySpecifiedTypes() reads tracked holders and memoized on-demand pricings instead of calling getType(), and the scope-state read family (getStateType()) derives narrowable expressions' types from tracked state. NodeScopeResolver pushes a storage around every analysis unit, consumes stored results everywhere it used to ask the scope, narrows loop/switch/foreach scopes through the composed helpers, flushes pending fibers only at body boundaries, and resets per-file state through the tagged resettables. FiberNodeScopeResolver stores full results and memoizes on-demand flush walks per file; FiberScope answers settled stored results without a fiber switch. TypeSpecifier is dropped from the NodeScopeResolver constructor (the testing harness follows), precisely resolved class constants are no longer remembered as conditional expressions, and the baseline follows the moved code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
Every handler now expresses its type and narrowing through the callbacks on its ExpressionResult; the interface methods have no implementations or callers left. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
The conditional-expression group scan validates the first holder and re-prints its expression for the invalidation key instead of trusting the group map key, and nodeKey() loses the keepVoid suffix now that void projection happens at the value-read boundary. The native ScopeOps twin mirrors the change and its member order is re-synced with the PHP side; the keepVoid interned string leaves the native key printer too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
August 14, 2026 18:41
98028f1 to
613afb6
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