Skip to content

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

Open
ondrejmirtes wants to merge 335 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 335 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#2032

Closes phpstan/phpstan#10786

Closes phpstan/phpstan#13253
Closes phpstan/phpstan#14396
Closes phpstan/phpstan#11953
Closes phpstan/phpstan#13802
Closes phpstan/phpstan#13789
Closes phpstan/phpstan#12780
Closes phpstan/phpstan#14914
Closes phpstan/phpstan#14908

🤖 Generated with Claude Code

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?

…tead of Scope::getType in property/nullsafe/boolean/assignop handlers
The disjunction-truthy / conjunction-falsey union recovery (an
expression both branch scopes narrow but the exact merge left
unconstrained) read the ask scope for its does-it-actually-narrow
gates. SpecifiedTypes now carries it as a DeferredSpecifiedTypesAugment
- the branch types stay pinned from the operand-walk filtered scopes at
compose time - and MutatingScope::applySpecifiedTypes() evaluates the
gates against the applying scope and unions the produced entries into
the applied batch.
The disjunction-truthy projection of conditional-holder narrowings
discovered its candidates in the ask scope's registered holders and
gated them against ask-scope state. It is now a
DeferredSpecifiedTypesAugment carrying the operand-walk truthy scopes;
candidate discovery and the gates run against the applying scope in
MutatingScope::applySpecifiedTypes().

With this and the two previous steps, the boolean narrowing helpers'
ask-scope parameter feeds only the child-result recursion and the
disjunction's decided-operand guards - all holder building and
branch-union recovery is symbolic until application.
The decided checks in specifyDisjunction (left decidedly false - use
the right narrowing only; left decidedly true or right decidedly false
- use the left) read the operands' types on the asking scope. They now
read the operands' walk-position types - the results' own evaluation
points, matching the || handler's own typeCallback - and take only the
asked flavour bit. Affects the verdict callbacks of BooleanOrHandler,
TernaryHandler's decomposition and EmptyHandler's isset/truthiness
disjuncts.
…tion point

Same move as the disjunction decided guards: the comparison's own
verdict in BinaryOpHandler (previously read from ask-scope storage or
tracked state with a callback fallback), the coalesce's
right-side-decidedly-false checks in both its specify and create
callbacks, and createSubjectTypes' nullsafe short-circuit null-ruled-out
check now read the walk results' own memoized types; only the asked
flavour bit follows the asking scope.
The issetability gates of empty() narrowing and the coalesce falsey
narrowing read the asking scope; they now run on the handler's captured
beforeScope (flavour-mapped by the asked bit), like the corresponding
typeCallbacks already did. CoalesceCompositionHelper's
getFalseySpecifiedTypes takes an explicit evaluation scope; the asking
scope stays only as the conduit for the left side's narrowing fan-out.
… ask

The isset/empty/ternary decompositions rebuilt their branch scopes from
the asking scope on every specify ask. The ternary now reuses the
operand walks' own memoized truthy/falsey scopes (the cond/if/else
results); empty() and multi-subject isset() derive their fold scopes
from the handler's beforeScope once, lazily, and reuse them across asks
- isset() additionally memoizes the whole accumulated conjunction
closure, which is ask-independent after this change.
… synthetic

`$x?->prop` / `$x?->m()` narrowing walked a synthetic
BooleanAnd(NotIdentical($x, null), plainTwin) on the asking scope per
ask. Both handlers now compose it through specifyConjunction: the
receiver-not-null side is the captured receiver result narrowed by
NullType in the negated context (the equality null slice), the plain
twin side is the already-captured fetch/call result, the left-truthy
scope is the ensured-non-null scope the twin was walked on, and the
receiver-is-null branch scope derives lazily from beforeScope. The
fabricated NotIdentical is only printed into holder keys, never walked.
IssetHandler's top-level chain reader and single/multi-subject chain
narrowing builders received the asking scope for their state reads
(chain-link re-evaluation, hasVariableType gates, issetability); they
now run on the handler's flavour-mapped beforeScope. The decomposition
fold conduits keep their given scopes, which are evaluation-point
derived since the fold bake.
ExpressionResult::getSpecifiedTypes(TypeSpecifierContext, bool) computes
the narrowing once on the flavour-mapped beforeScope and memoizes it;
getSpecifiedTypesForScope() reduces every asking scope to its flavour
bit and delegates. This is what the symbolic SpecifiedTypes work was
for: alternative-form terms, conditional-holder recipes and deferred
augments carry the state-dependent math to applySpecifiedTypes(), so a
single memoized SpecifiedTypes serves every asking position - rule
bridges, boolean compositions and branch derivations alike.
The scope parameter carried no information beyond its flavour bit since
the per-context memoization - callbacks were only ever invoked with the
flavour-mapped beforeScope. The signature now says so:
callable(TypeSpecifierContext, bool $nativeTypesPromoted). Handlers
whose narrowing needs an evaluation scope construct it from their
captured beforeScope; child-result conduits go through
getSpecifiedTypes() directly. The boolean narrowing helpers keep their
MutatingScope evaluation-scope parameter, now always supplied by the
handler.

This closes the resolve-type-rewrite goal: an expression's narrowing is
a pure function of (context, flavour), computed once at the
expression's own position in the program.
Upstream's fix (b1c0eb2): the right-side-decidedly-false narrowing
of ?? must not narrow the left to non-null when the coalesce context
still permits a falsey value. Re-applied to the composed specify
callback the rebase replayed over it.
@ondrejmirtes ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from f98892f to 4455baa Compare July 6, 2026 22:20
The boolean narrowing helpers took the operand branch scopes as eager
arguments, so every specify ask on a deep boolean chain derived the
opposite-polarity scope of every level - the walk only computes falsey
scopes for || (and truthy for &&), so each missing one cost an
applySpecifiedTypes with a growing union, quadratic over the chain.
They are thunks now, resolved only when a consumer genuinely needs the
state: the branch-union recovery when it has candidate expressions,
the holder projection when a candidate passes the applying-scope gates,
holder recipes when a non-variable target needs pinning.

Net analysis time of tests/bench/data/or-chain-resolve-type-blowup.php
drops from ~5s to ~1.2s, level with the 2.2.x baseline; the remaining
super-linear member flows (the growing object~(C1|...|Ck) subtraction
re-normalized per level) are shared with the old-world representation.
The create-side analog of the specifyTypesCallback flip: a type
constraint's fan-out is computed at the expression's own evaluation
point, with the asking scope reduced to its flavour bit.
ExpressionResult::getCreatedTypes() is the new entry point;
getCreatedTypesForScope() delegates. Handlers construct their
evaluation scope from the captured beforeScope, the same recipe as the
specify flip.
Two reads made every match arm see the full original subject type
instead of the subject minus the previous arms' values:

- MatchHandler computed its arm verdicts from the subject result's
  walk-position type; they now read the threaded per-arm scope state
  (getStateType), which carries the previous arms' subtractions.
- A rule asking about a synthetic comparison (MatchExpressionRule's
  Identical($subject, $armValue)) walks it on demand over a duplicate
  of the live storage, so the subject hit its stored result and
  answered its walk-position type - the per-scope re-pricing channel
  the typeCallback flip removed. BinaryOpHandler's operand reader now
  re-prices operands via getTypeOnScope() with variables opted in
  (the new repriceVariables parameter), reading them from the walk's
  own beforeScope - the asking scope for an on-demand synthetic.

Fixes ten MatchExpressionRuleTest known-reds; the walk-position
semantics of stored-result reads and chain folds are unchanged.
Replaces the repriceVariables flag with the model it was approximating:
when a result is asked for its type on a scope, the scope - not the
result - owns the answer for every expression it is authoritative
about: variables it knows (including $this and parameters, via the
variable read), and any expression it tracks a holder for. Only
expressions the scope knows nothing about answer from the memoized
walk-position type. This is getStateType's contract applied at the
ExpressionResult boundary; the old gate excluded variables only because
the raw holder read behind it could not answer $this or parameters.

The one consumer that genuinely wanted a position-fixed value - the
foreach-by-ref write-through, which rebuilds the iteratee with the
value variable's latest type - was relying on the stale stored-result
read to mean "the iteratee at foreach entry". It now captures that
entry type explicitly in the intertwined expression; a live read would
union transient mid-iteration value states into the array, which is the
loop convergence's job.

Eighteen more known-red tests pass, including the rest of the match-arm
cluster: rules asking about synthetic comparisons on arm-narrowed
scopes now see the narrowed subject through any expression shape, not
just variables.
An isset/empty/?? ensure writes non-null device types into scope state
so nested fetches walk without spurious possibly-null noise - which
made a nullsafe operator inside the ensured subject read its receiver
as non-null and drop the short-circuit null from its own type
(isset($this->get()?->aaa) saw int instead of int|null, so the isset
rules reported "not nullable" instead of "unnecessary ?->").

NonNullabilityHelper now keeps a stack of the ensures in effect during
the walk, recording the pre-device types its results already carried;
the nullsafe handlers consult it for the receiver's real type. The
device types keep doing their job for everything else.
PHPStan runs with gc_disable(), so anything reachable stays allocated.
The Ternary/Match handlers capture operand results in WeakMaps keyed by
AST nodes - and the parser cache retains ASTs across files, so those
entries never died, holding every analysed file's result graph
(callbacks, scopes, types) until the process ended. NodeScopeResolver
now resets such per-file state at the start of each file's analysis;
measured on self-analysis, live results at file boundaries drop to zero
and make phpstan's used memory goes from 2.68 GB to 2.38 GB.

The truthyScopes/falseyScopes caches on MutatingScope memoized whole
filterBy*() result scopes per expression; instrumentation showed zero
hits across self-analysis - the engine reads branch scopes from
expression results now, and rule-facing filterBy*() calls do not repeat
per scope. Deleted, along with their propagation in the scope-copying
factories.
… the closure-type cache to a per-file WeakMap
…released after their return-statements rules
The isset-chain descriptor now recovers optional-property types in
isset()/?? positions itself, so the global promotion only changed bare
reads away from ErrorType.
A stored ExpressionResult only answers an on-demand ask when the asking
scope agrees with its evaluation position on the variables the expression
reads; a counterfactual walk (an extension re-binding a variable, e.g.
array_filter evaluating its callback per constant element) re-processes
the node on its own scope. A trivially-matching stored answer consumed at
a foreign position is re-anchored to the asking scope so it does not
thread the original walk's scopes onward.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment