Skip to content

Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857

Open
ondrejmirtes wants to merge 32 commits into
2.2.xfrom
resolve-type-rewrite-2
Open

Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857
ondrejmirtes wants to merge 32 commits into
2.2.xfrom
resolve-type-rewrite-2

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jun 12, 2026

Copy link
Copy Markdown
Member

Groundwork for the "new world" where an expression is traversed once: after processExpr, its ExpressionResult knows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementing TypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, the TypeSpecifier dispatcher) are guarded behind NewWorld::disableOldWorld() and get mass-deleted in PHPStan 3.0.

What's on the branch, bottom up:

  • Guards + ExpressionResultFactory: old-world type resolution entry points throw when NewWorld::disableOldWorld() is flipped (the migration meter); all ExpressionResult construction goes through a generated factory.
  • ExpressionResult carries beforeScope, expr, typeCallback, specifyTypesCallback and is stored per node in ExpressionResultStorage (layered O(1) duplicate()), replacing the stored before-Scope.
  • ExprHandler / TypeResolvingExprHandler split: resolveType/specifyTypes move 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; NodeScopeResolver pushes the storage of the analysis in progress through MutatingScope::pushExpressionResultStorage() (always popped in finally, throwing on imbalance), and MutatingScope answers 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 in bin/phpstan. Also adds MutatingScope::applySpecifiedTypes - filterBySpecifiedTypes without Scope::getType().
  • First two migrations: ScalarHandler and ArrayHandler no longer implement TypeResolvingExprHandler. 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] infers array{1, 2, 1, 3, 1, 2}.

Verified: full test suite green, make phpstan clean, and analysis memory back at baseline (no leak from the result graph despite gc_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

return $this->withFlavor(false);
}

private function withFlavor(bool $fiber): self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this read withFiber?

@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch 2 times, most recently from eb31077 to 59cbf22 Compare June 19, 2026 11:44
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 59cbf22 to 125cf22 Compare June 20, 2026 11:56
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch 4 times, most recently from f98892f to 4455baa Compare July 6, 2026 22:20
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 61fe06e to e38aadd Compare July 16, 2026 14:56
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
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch 12 times, most recently from fb22d34 to 84b1614 Compare July 28, 2026 17:31
ondrejmirtes and others added 17 commits August 14, 2026 20:40
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
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 98028f1 to 613afb6 Compare August 14, 2026 18:41
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.
A rule asking the type of a virtual node itself (BooleanOrNode, ...)
parks its fiber - the node is never stored - and the flush walks it on
demand, hitting processExprNodeInternal()'s unhandled-expr throw and
aborting the whole file's analysis with an internal error.
MutatingScope::resolveType() already answers such nodes with mixed;
processExprOnDemand() now takes the same fallback, keeping the main
walk's throw for real source nodes.
… state

A rule callback may derive the scope it was handed - e.g. assignExpression()
pinning a call-site literal onto a parameter variable, the way callback-
analysing tooling re-analyses a callee body via the public processNodes()
API with more specific argument types. FiberScope's settled-result fast path
and post-suspend read returned the naked walk-position type, ignoring such
derivations. Both now consume through askScopeVariableStateMatches() in a
rule-facing mode: variables unknown to the asking scope and variables
narrower at the evaluation position (the coalesce right side priced on the
left's falsey branch) leave the walk answer standing; an asker-side
refinement re-prices on the asking scope's state.

MutatingScope::toFiberScope() seeds the created scope with its origin (a
WeakReference - a strong back-reference would cycle with the $fiberScope
cache and never free with GC disabled), so toMutatingScope() answers with
the walk scope itself and the guard's beforeScope identity check hits for
same-position asks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment