Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857
Open
ondrejmirtes wants to merge 378 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 378 commits into
ondrejmirtes wants to merge 378 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
…vingExprHandler is gone" This reverts commit 72d3f6eb6a8e0821b20e152a465fd404cebf9c0f.
…back Wire a createTypesCallback on AlwaysRememberedExprHandler that constrains both the remembered wrapper key and the inner expression by composing the inner's child result - the inside-out equivalent of TypeSpecifier::create()'s AlwaysRememberedExpr fan-out. Additive: raw-Expr callers still go through create()->createForExpr; nothing composes a remembered child result yet, so behaviour is unchanged. A foundation for migrating the equality narrowing off the raw create() path.
Wire createTypesCallback on NullsafePropertyFetchHandler and NullsafeMethodCallHandler: the plain property fetch / method call narrowed by the constraint, unioned with "receiver is not null" - the inside-out equivalent of TypeSpecifier::createNullsafeTypes(). A deeper ?-> in the receiver surfaces through the parent handler composing the var result, not by walking the chain here. Additive: the raw-Expr create()/createNullsafeTypes() path is intact and nothing composes a nullsafe child result yet, so behaviour is unchanged.
Wire createTypesCallback on MethodCallHandler and StaticCallHandler: a narrowable (non-side-effecting) call narrows itself; an impure one narrows to nothing - the inside-out equivalent of createForExpr's MethodCall/StaticCall purity gate. Reuses the existing isMethodCallNarrowable/isStaticCallNarrowable predicates (which read the receiver/class child result, not Scope::getType). Unlike the no-op foundations, a call result is already composed in coalesce / instanceof / assignment contexts, so this applies the purity gate there too (matching createForExpr); the suite stays green.
Wire createTypesCallback on FuncCallHandler: a narrowable (non-side-effecting, non-first-class) function call narrows itself; an impure one narrows to nothing - the inside-out equivalent of createForExpr's FuncCall purity gate. Reuses the existing isFuncCallNarrowable predicate (reads the name child result, not Scope::getType). Mirrors the method/static call foundation.
Complete the nullsafe createTypesCallback to mirror createForExpr's `?->` handling instead of only its !containsNull branch: compute containsNull from the constraint type and the ?->'s own type (read inside-out via the extracted $nullsafeTypeCallback, not Scope::getType). When the constraint permits the short-circuit's null (containsNull), narrow the ?-> node itself; otherwise emit the plain-chain narrowing, the original ?-> double-key, and "receiver is not null". This removes the createForExpr Scope::getType from the composed path.
Switch specifyTypesForNormalizedIdentical's operand narrowing from raw TypeSpecifier::create($operand,...) to DefaultNarrowingHelper::createSubjectTypes with the operand's stored result, so an assignment / remembered wrapper / call / nullsafe operand fans out through its own createTypesCallback. This deletes the two manual AlwaysRememberedExpr unwraps the helper carried. createSubjectTypes now falls back to create() when there is no composable result (a synthetic node reached via specifyTypesInCondition, or an operand not in the current storage) instead of emitting a bare single entry - the create-side analog of getChildSpecifiedTypes' specifyTypesInCondition bridge, transitional until synthetic-node narrowing is removed.
…orSubject Replace every remaining raw TypeSpecifier::create($subject, ...) call in the helper with a createForSubject() that composes the subject inside-out from its stored result (falling back to create() for synthetic subjects). The helper no longer constructs narrowing for operands / call args / constants via the raw create() path; each subject fans out through its own createTypesCallback. Unifies the createSubjectTypes calls introduced last commit onto the same helper and drops the now-unused $getResult closure.
…orSubject Promote createForSubject to DefaultNarrowingHelper (same signature as TypeSpecifier::create) and switch the remaining raw create() callers to it: BinaryOpHandler (count/strlen/comparison narrowing), IssetHandler, and ConditionalExpressionHolderHelper. Each subject now narrows through its own stored result's createTypesCallback, falling back to create() only for synthetic subjects. EqualityTypeSpecifyingHelper delegates to the shared method instead of a local copy; ConditionalExpressionHolderHelper no longer needs the TypeSpecifier dependency.
getChildSpecifiedTypes' fallback (a child with no wired specifyTypesCallback, or a synthetic node with no result) now processes the child on demand and asks its result for the narrowing - $s->specifyTypesOfNewWorldHandlerNode(...) ?? default - the same path specifyTypesInCondition already routes handler-supported nodes through, minus the old-world dispatcher. The create-side fallback deliberately stays on create(): its handlers' createTypesCallbacks self-reference their own node, so on-demand routing there would re-enter infinitely.
Extract the on-demand narrowing of getChildSpecifiedTypes into a reusable DefaultNarrowingHelper::specifyTypesForNode(scope, node, context), and route BinaryOpHandler's synthetic re-dispatches (NotIdentical->Identical, NotEqual->Equal, inverse comparisons, count===) through it instead of the old-world specifyTypesInCondition. Each synthetic is a different node type than the one being processed, so on-demand processing cannot self-cycle.
Route EqualityTypeSpecifyingHelper's re-dispatched conditions (==null/true/false, Equal->Identical, ->Instanceof_, preg_match/operand dispatch) through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world specifyTypesInCondition. specifyTypesInCondition already funnelled these synthetic nodes through specifyTypesOfNewWorldHandlerNode/processExprOnDemand, so the path and its termination are unchanged; none rebuilds an Identical with the same operands, so there is no self-cycle.
empty($x) narrowing builds a synthetic `!isset($x) || !$x` BooleanOr; route it through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world specifyTypesInCondition. Its BooleanNot leaves now resolve through the on-demand getChildSpecifiedTypes path; nothing in that chain re-synthesizes the empty() node, so it terminates. EmptyHandler no longer needs the TypeSpecifier dependency.
BooleanNotHandler narrows its operand by composing directly from the operand's own ExpressionResult (getChildSpecifiedTypes with the captured result) instead of re-resolving the node - the natural inside-out flow, since the operand was just processed above. IssetHandler rewrites multi-var isset() into a synthetic BooleanAnd chain of single-var issets (no captured result for the chain) and resolves it on demand via specifyTypesForNode; the single-var path builds no further chain so it terminates. BooleanNotHandler no longer needs TypeSpecifier.
BinaryOpHandler hands EqualityTypeSpecifyingHelper a $resultFor closure mapping each operand node to the ExpressionResult it already produced. The helper's existing-operand specify dispatches (==false/==true, preg_match ===) now compose the operand's narrowing from that captured result via getChildSpecifiedTypes rather than re-resolving the node through specifyTypesForNode. Behaviour is unchanged - the on-demand path resolved the same stored result - but the common operands are now composed inside-out directly; a non-operand or unwrapped node maps to null and still falls back to on-demand resolution.
… unwraps createForSubject takes an optional $resultFor lookup so a parent handler that already produced the operand's result composes from it directly; an AlwaysRememberedExpr operand then fans out to wrapper + inner through its own createTypesCallback. Thread it through EqualityTypeSpecifyingHelper's operand createForSubject calls (and finish the specify side in specifyTypesForConstantBinaryExpression), then delete the manual "if ($x instanceof AlwaysRememberedExpr) narrow the unwrapped inner" blocks in the never and finite-type branches - the composed path covers them (it also narrows the wrapper key, matching create()'s double-key). The remaining AlwaysRememberedExpr instanceof checks are structural: they unwrap to inspect the inner node's syntax (count/preg_match/get_class/::class), not to narrow.
The foreach handling re-priced its iteratee with two on-demand walks per processing: once on the truthy-narrowed scope (the iteratee narrowed to a non-empty array) and once on the post-loop scope (the body may have modified it). Both narrowing and modification are tracked by the scope, so getTypeOnScope()'s authoritative read answers without a walk; the new ExpressionResult::answersOnScope() gates the rare remainder - an untracked iteratee whose inputs the loop body changed - onto the reprocessing path. In nested loops inside closures these walks multiplied with the convergence passes (91 of the 237 walks of the census's hottest non-trait node came from these two sites).
…result Every foreach processing narrowed the body scope by a synthetic NotIdentical($iteratee, []) walked through the on-demand dispatcher - at three sites, one of them inside the convergence loop, so the iteratee and the sentinel were re-priced up to LOOP_SCOPE_ITERATIONS + 2 times per processing. The walked synthetic delegates to IdenticalNarrowingHelper::specifyIdenticalAgainstType anyway; call it directly with the iteratee's result and the empty-array sentinel, compute the narrowed scope once, and reuse it at all three merge points. The walk remains as the composition's miss seam (FuncCall families, dynamic class-constant subjects).
…scope The switch handling narrowed each case's branch scope by walking a synthetic Equal(subject, case) through the on-demand dispatcher; the walk delegates to IdenticalNarrowingHelper::specifyEqual anyway, so call it directly with the subject's and the case's results (the walk stays as the composition's miss seam). The match and switch exhaustiveness checks re-walked their subject on the "no arm/case matched" scope; that narrowing is tracked by the scope, so getTypeOnScope's authoritative read answers without a walk, gated by answersOnScope like the foreach iteratee.
…alking CoalesceCompositionHelper's left-is-set branch re-walked the left expression on the isset-narrowed scope for every ?? / ??= type ask. The chain narrowing is tracked by the scope (getTypeOnScope's authoritative read), so the walk only remains for an untracked left side, gated by answersOnScope.
…alking Same pattern as the foreach iteratee and match/switch subjects: the truthy narrowing is tracked by the scope, so getTypeOnScope's authoritative read answers without a walk; answersOnScope gates the untracked remainder onto the reprocessing path.
Rules ask about the same (usually synthetic) expression repeatedly across statement boundaries; each parked fiber was resolved by a fresh on-demand walk on a duplicated, discarded storage, so every subsequent ask re-walked the node and its whole subtree. The channel-aggregated census put this at ~31% of all corpus reprocessings (357k flush walks, 217k of them re-asks of an already-priced expression, 99.9% answerable from the last result). The last flush result is now kept per expression (WeakMap - entries die with the AST) and reused when nothing the expression reads changed since the walk, on both flavours; the resumed fiber reads the result's own walk-position type, so scope-authoritative answers deliberately do not qualify. State mismatches re-walk as before.
The memo's CPU win is real (-23% self-analysis user CPU, ahead of 2.2.x by ~15%) but retaining the flush-walk results pins their scope and callback graphs: +1.3GB, which nondeterministically breaks make phpstan's 450M per-worker limit. Every slim variant measured (eager types, native re-walk, downgrade generations) loses the CPU win - the lazily memoized native flavour on the retained result is what answers repeated asks for free. Re-land after ExpressionResult retention shrinks (callback-scope-removal) or with a cheaper native strategy.
A closure/arrow function passed as a call argument paid up to four full body walks: the faithful-return gather walk, the real walk, a second walk on the promoted scope for the native flavour of the stored result, and (arrows) an on-demand walk when the invalidate-expressions read asked Scope::getType() before the result was stored. - The native flavour is now built from the same gathered returns/yields, reading the stored native types off the walk's scopes (buildClosureTypeForClosure(native: true), mirroring what buildClosureTypeForArrowFunction() and the plain handlers already do). - The builders now compute the closure-type cache key on the flavour-correct scope and callable parameters: the native build no longer clobbers the phpdoc cache slot (previously buildClosureTypeForArrowFunction(native: true) cached the native return type under the phpdoc key), and a later getClosureType() ask on the promoted scope answers from the build instead of re-walking. - The arrow branch builds the ClosureType once, stores the result first, and reads the invalidate expressions off the built type. - The gather walk only runs when acceptor selection is type-driven (multiple variants, named-argument variants, or templates/conditionals to resolve); otherwise the signature-only shallow type keeps the count/name bookkeeping correct without a body walk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s consumed For a multi-variant or templated callee, every argument re-ran the full type-driven acceptor selection (intrinsic overrides + template inference over all args padded to the full count) - O(args^2) with a full generic resolution per argument. Only a closure/arrow function consumes the generic-RESOLVED parameter type: its parameters and body scope are typed from the resolved callable(T), directly or through the in-function-call stack when nested inside the argument. Every other argument reads variant-stable facts off its parameter (by-ref flag, callable bookkeeping), so a single all-mixed count-stable selection now serves all of them; the per-argument gathered-so-far selection remains for arguments containing a closure/arrow function. Cuts acceptor selections by ~73% on a templated-call corpus with byte-identical output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ults Loop-convergence passes and expression-level loop processing wrote into throwaway storage duplicates that never reached the scope's storage stack, so every in-pass ask (applySpecifiedTypes pricing, stored-result reads) missed the pass's own results and re-priced real nodes on demand each iteration. - The conditional storage push moves from processStmtNodesInternal into processStmtNodesInternalWithoutFlushingPendingFibers, covering the closure by-ref convergence passes that call it directly. - While/Do/For condition walks, For loop-expression walks, and foreach enterForeach virtual assigns now push their pass storage for the pass's duration (branch-scope derivations forced inside the region). - The While post-loop falsey narrowing and the foreach iteratee re-pricing walk on a duplicate of the live storage instead of a fresh one, so subresults whose state did not change answer from the stored results. - Do/For read the always-iterates check off the condition's processed result instead of a scope-based read that was a guaranteed storage miss (the condition was only ever stored into discarded convergence duplicates) followed by processing the condition a second time. Cuts on-demand walks by 31% on a loop-heavy corpus and 15% on TypeCombinator.php self-analysis with byte-identical output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isGenerator() recursively scanned the whole function body on every call, and reflections for the same node are recreated per ask - the yield scan ran 1.3M times on the src/Analyser+src/Rules corpus. The answer is a property of the AST node, so it is memoized on the node itself (WeakMap). Self-analysis user CPU: -5%.
FiberScope::getType()/getNativeType() suspended on every ask; for an already-stored expression the round-trip through the fiber machinery hands back the very result the scope's current storage holds. The new findSettledExpressionResult() (the find path's provisional gate, now shared) answers those asks in place under exactly the resumed fast path's conditions; misses and filtered/promoted asks suspend as before.
…keys shouldInvalidateExpression() re-scanned the holder's whole subtree with a NodeFinder on every invalidation (after a substring pre-filter on the printed keys, whose compositional-printer invariant needed carve-outs for virtual nodes and key suffixes). The scan establishes a property of the holder's expression alone, so each holder now lazily indexes the node keys of its sub-expressions once - holders are shared across scope copies, so the single subtree scan amortizes over every later check - and the check becomes one array lookup, exact by construction. '$this' invalidations keep the finder: they also match self/static/parent and the current class name, and that name resolution depends on the asking scope. invalidateExpression() was 7.8% of the src/Analyser+src/Rules run; make phpstan user CPU: -2.5%.
Rules ask about the same (usually synthetic) expression repeatedly across statement boundaries; each parked fiber was resolved by a fresh on-demand walk on a duplicated, discarded storage, so every subsequent ask re-walked the node and its subtree - the single largest reprocessing channel in the corpus census (31% of reprocessing walks, 357k flush walks per run, 99.9% of re-asks answerable from the previous pricing). Unlike the reverted first attempt (436b282a0f), the memo retains no walk results: only the read-variable state snapshot (ReadVariableStateSnapshot, the retained half of askScopeVariableStateMatches) and the two materialized types per expression. A hit fabricates an effect-free eager result at the ask position - exactly where a fresh walk's result would sit - so no scope or callback graph outlives the walk and memory stays at parity. Both flavours are materialized at store time; with the walk contexts hot this costs far less than the re-walks it replaces. make phpstan user CPU: -3.5%, closing the gap to 2.2.x to about +1%.
createConditionalExpressions() scanned every holder map in full - twice per merge - although each of its loops only ever selects entries whose our/their/merged holders differ: guards require the merged type to differ from ours, targets likewise, and the exclusion list is only consulted for such entries. mergeVariableHolders() knows exactly which keys those are (everything that is not one shared holder on both sides), so it now reports them and the conditional creation iterates just that diff - typically a handful of keys instead of hundreds of holders. createConditionalExpressions was the largest self-time item in the branch's SPX profile (5.8s of a 3x-inflated scoped run); make phpstan user CPU: -1%.
Loop convergence re-walked the body until the exit scope stabilized; 94% of converging loops converge exactly one pass after the last change, so that final re-walk is pure verification. Walking is deterministic in the entry scope: when a pass's merged entry equals the previous pass's entry, the exit is the previous exit and the walk is skipped (27.5% of convergence re-walks on the scoped corpus). Measured CPU-neutral on make phpstan - the skipped bodies are small - but the wasted walks are gone and larger loop bodies benefit proportionally.
specifyExpressionType() copied both holder maps and constructed a scope per specification - and per array-dim level through its parent recursion. The body now writes in place on an unpublished working copy (specifyExpressionTypeInPlace, same contract as processConditionalExpressionsAfterSpecifying), single callers open one copy, and applySpecifiedTypes() shares one working copy across its whole batch, publishing only when another scope derivation interleaves. Measured CPU-neutral on make phpstan - the whole-map COW copies turn out cheaper than modeled at typical holder-map sizes - but one copy per batch replaces one per specification-and-dim-level, and the in-place seam is what future batching builds on.
…ion position Body walks pair a gathering closure (impure points, invalidations, execution ends, return statements) with the rule-facing node callback and read the gathered arrays as soon as the walk returns. With the whole composite running inside a fiber, a rule parking on an unsettled expression deferred the gathering past that read - a closure assigning static::$prop lost its propertyAssign impure point whenever a rule suspended on the PropertyAssignNode, so the invocation was reported as having no side effects. GatheringNodeCallback makes the pairing explicit: FiberNodeScopeResolver unwraps it and runs the gatherer at the emission position, deferring only the rule-facing remainder to a fiber.
…of native-typed properties Three connected changes to the isset/empty/?? verdict machinery: The maybe-uninitialized gate in IssetabilityResolution::isSet() drops the nativeHasDefaultValue conjunct - the old-world MutatingScope::issetCheck() never had it, only the rule side does (the two deliberately diverge upstream). In its place the gate learns to read conditional-expression entries about the fetch as initialization evidence: such entries exist only when the fetch was narrowed in an evaluated condition, and evaluating a condition reads the fetch, which would have thrown on an uninitialized typed property. A typed-property read witnesses initialization. IssetHandler evaluates its verdict and narrowing callbacks on the post-revert scope instead of the scope before the subjects were processed: revertNonNullability() leaves an originally-untracked nullable chain link tracked at its original type, and that state is what the old world's narrowing evaluation saw. The falsey narrowing of a single isset() subject pins a native-typed property to null even when the verdict is maybe, as long as the inner chain is fully set: the maybe can only mean a nullable value or a possibly uninitialized property, and reading an uninitialized typed property throws instead of yielding a value - so in the !isset() branch any read that completes yields null.
… askers The raw target fetch of a property assignment is emitted to node callbacks at the top of processAssignVar() (rules and DependencyResolver listen on it and ask its type), but the assign flow never processed it as a read - under the fiber engine those askers parked forever and were flushed through the on-demand path, tripping the PHPSTAN_GUARD_NW diagnostic on ~320 suite tests. The property branches now price the target once at the pre-assign position, consuming the receiver's and name's stored results; storing it resumes the parked fibers with the pre-assign type. The pending-fiber guard also learns the same processed-node check the other three guards already have: a node whose per-body storage was released since it was stored (class-level rules asking about gathered method-body expressions) legitimately re-prices through the on-demand rule bridge and must not be flagged.
A parameter default is a constant expression - resolve it through InitializerExprTypeResolver like parameter defaults elsewhere, instead of Scope::getType() on a node the single pass has not walked.
…ssed The side-effect flip parameters (print_r's $return, var_export's, ...) read an argument's type to decide whether the call has side effects. At the pre-args position that argument is not walked yet; after processArgs() its result is stored and the read is answered from it.
…traction Upstream removed the then-unused import from MutatingScope; the branch's applySpecifiedTypes still guards array literals with instanceof Array_, which silently matched the nonexistent PHPStan\Analyser\Array_ and let array literals into the scope's tracked expressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
array-merge2: restore the key order upstream moved when getSortedTypes() stopped sorting in place. preg_replace_callback: the flags-refined closure parameter makes the arrow body's return type precise, so returning $m[0] under PREG_OFFSET_CAPTURE is correctly reported as returning an array where a string is required. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
The branch answers invalidation checks from the per-holder index of contained node keys in MutatingScope, so the extracted invalidateExpressionEntries/shouldInvalidateExpression/ containsExpressionToInvalidate/buildTypeSpecifications helpers have no callers left. Also drop the baseline entries for the unused-use closure errors that upstream's include/require handling (#6056) resolved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
July 16, 2026 14:56
61fe06e to
e38aadd
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#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