Skip to content

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

Open
ondrejmirtes wants to merge 20 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 20 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 3 commits August 14, 2026 20:31
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
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 119ce6f to 98028f1 Compare August 14, 2026 18:37
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment