Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857
Open
ondrejmirtes wants to merge 30 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 30 commits into
ondrejmirtes wants to merge 30 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
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
Rules and DependencyResolver receive a node's callback and immediately ask about the node or its subexpressions. Under fibers a pre-order callback parks on its first ask and resumes when the natural walk stores the result anyway - but a synchronously invoked callback (the plain resolver on PHP < 8.1) re-walked everything it asked about through the on-demand bridge: ~380k re-walks during self-analysis, +15% user CPU vs fibers. Expression nodes now emit their callback right after the handler's result is stored, and the expression-carrying statements (echo, return, expression statements) after their expressions are processed - in both cases with the scope captured at the entry position, so rules observe the same (scope, answer) pair as before. Self-analysis on the plain resolver drops from 470k to 107k on-demand walks; fibers are unchanged.
…lts are stored Continues the previous commit for the remaining synchronous-callback re-walk clusters: if/elseif/switch emit their statement callback right after the condition's result is stored (rules like the constant-condition and boolean-in-condition helpers ask about the condition), and prepareTarget() emits the raw assignment target's callback after the walk composed and stored the target's read result (DependencyResolver and the property rules ask about the target and its receiver). Scopes stay captured at the entry position. Self-analysis on the plain resolver drops from 107k to 79k on-demand walks - 14.6k of them on real nodes, down from 380k before the two commits.
…h callback The constant-condition rules listening on BooleanAndNode/BooleanOrNode ask about the raw binary expression, and foreach rules about the iteratee. The boolean handlers now store their result before emitting the virtual node (the later store in processExprNodeInternal is an idempotent re-store of the same result), and the foreach statement emits its callback after the iteratee's result is stored, with the entry scope.
Two diffuse per-event costs measured against the plain resolver: the store hook called processPendingFibersForRequestedExpr() for millions of stores although almost none have a pending fiber - an inline empty check skips the call and the object-id lookup; and gatherers received a FiberScope although they are engine code that never asks about types - they get the raw scope now, and the scopes they capture answer later asks through the storage hub like any MutatingScope.
…ensions Extends the argument priming to the two remaining lazily-invoked extension surfaces: the dynamic static-method return type extensions dispatched for constructors in NewHandler's exactInstantiation() (runs in the typeCallback), and the function/method/static-method type-specifying extensions (run at narrowing-apply time in the specifyTypesCallback). Both can ask Scope::getType() about the call's arguments after the walk's storage frame is no longer current; the primed storage answers those asks from the argument results instead of re-walking on demand. The eager surfaces (throw-type and parameter-out extensions) run during the handler with the walk storage current and need no priming.
OutputBufferHelper priced the incremented ob_get_level() type by walking a synthetic Plus of two TypeExprs through Scope::getType() - a core-engine synthetic re-walk. It is now a service that calls InitializerExprTypeResolver::getPlusType() on the operand types directly.
Two more core synthetic re-walks replaced by the logic they were fishing for: StaticCallHandler priced `new $classExpr` through Scope::getType() to learn what a class-string receiver instantiates - that is getObjectTypeOrClassStringObjectType() on the receiver's own result; and FuncCallHandler's clone-with support walked a synthetic Clone_ although the object argument was just processed - CloneHandler's type logic is now an extracted resolveCloneType() both call sites share.
The static-call promoted-properties check priced $this through a synthetic Variable walk - it is a plain scope-state read. The parent-instantiation synthetic New_ walk in exactInstantiation() stays: it re-resolves the parent constructor's template types from the arguments, which a direct recursion cannot - now documented at the site.
…ider Resolving an unqualified name probes the namespaced variant first, and a miss surfaces as a constructed-and-thrown IdentifierNotFound inside the reflector - repeated for every re-ask of the same name. The single-pass engine's per-flavour callbacks re-ask the same names many times per file (2,500 exception throws while analysing ConstantArrayTypeTest alone). The resolution is now memoized per (namespace, name as written); the key keeps the asked case because the resolved name preserves it for the incorrect-case rules.
The processArgs() restructure lost two things the pre-ArgsResult shape had: the resolved acceptor was selected (and generic-resolved) for every call although a single template-free acceptor IS the resolved acceptor - the fast path the original selectFromArgs() took - and the per-argument type-driven predicate re-traversed the acceptor's parameter types on every argument instead of once per call. Restoring both cuts GenericParametersAcceptorResolver::resolve from 5,175 to 648 calls while analysing ConstantArrayTypeTest.
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