Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857
Open
ondrejmirtes wants to merge 394 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 394 commits into
ondrejmirtes wants to merge 394 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
…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
The per-holder contained-node-keys invalidation lived as private MutatingScope methods, out of reach of the turbo twin — with the extension active, every one of the ~2.3M shouldInvalidateExpression() calls of a self-analysis run paid a userland frame that 2.2.x answers natively. Extracted back into ScopeOps statics (the shape the pre-index code had), called unconditionally; '$this' invalidations keep the finder on the asking scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
…asses ExpressionResultStorage stores ExpressionResults in id-keyed tables with an O(1) fallback-chain duplicate() and gains mergeResults(), mirroring the twin's SplObjectStorage model. ExpressionTypeHolder gains the lazy containedNodeKeys subtree index. ScopeOps: mergeVariableHolders reports differing keys through the new by-ref parameter, createConditionalExpressions iterates only those keys, nodeKey drops the retired keep-void suffix, buildTypeSpecifications goes away with its PHP twin, and shouldInvalidateExpression/invalidateExpressionEntries answer from the holder's contained-node-keys index (built natively on first use and cached in the holder's slot) instead of the subtree scan - measured about -1.5% make phpstan user CPU against the 2.2.x-with-turbo control. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
The call handlers' throw-point leg resolved the return type eagerly and directly, outside the result's per-flavour memo - the first later type read ran resolveReturnType() again (2.78 resolutions per method call on the self-analysis corpus; dynamic return type extensions such as sprintf's ran twice per call). The eager leg now reads through the stored preliminary result, and the memoized value seeds the final result's raw own-type slot via the new ExpressionResult $resolvedType parameter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
…riting it The four call handlers (function, method, static, new) stored a preliminary result before the throw-point leg and then built a second ExpressionResult with the resolved scope and effects, losing every memo the preliminary had accumulated - including the eagerly resolved return type. finalize() turns the stored preliminary into the final result in place, carrying the own-type and narrowing memos and dropping only the truthy/falsey scopes derived from the preliminary scope. The $resolvedType factory seed from the previous commit is superseded and removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
The closure by-ref fixpoint walked its intermediate passes with a top-level statement context, so every inner loop re-converged from scratch on every pass - convergence within convergence, which the loop handlers themselves avoid by walking their passes with enterDeep(). A recursive by-ref closure with nested loops re-created the innermost expressions' results up to 78 times; deep-context passes bring it in line with the loop handlers' discipline, and the final top-level walk keeps full precision. Inherited from mainline (2.2.x has the same top-level pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
Rebase adaptation: the mainline restructuring of processArgs assigns $parameterType on every path before the closure/arrow-function branches, which the new nullCoalesce.unnecessary check now proves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5cXTK6VFMziQHez43qhea
Rebase adaptation: ScopeOps::buildTypeSpecifications() no longer exists on this branch (superseded by the per-holder index), so the differential smoke block resurrected from the mainline side tested a method with no PHP twin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5cXTK6VFMziQHez43qhea
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5cXTK6VFMziQHez43qhea
resolveScopeStateType() derives the base a narrowing is intersected into from tracked state instead of re-walking. Its ArrayDimFetch case called NeverType::getOffsetValueType() directly, yielding never - but genuine pricing of an offset read on never yields ErrorType (a benevolent mixed). A narrowing applied in a dead branch (is_object($x[0]) with $x already never) intersected object against never and lost the narrowed type, silencing every rule that reads it - e.g. the non-ignorable 'Accessing ::class constant on an expression is supported only on PHP 8.0 and later' disappeared at phpVersion < 8.0 because ClassConstantRule bailed on the ErrorType it derived from the never. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
The downgrade step silently leaves ?-> untouched, so the transformed sources kept PHP 8.0 syntax and would fail to parse on PHP 7.4. Rewritten as explicit null checks on a hoisted receiver; the first-class-callable case was already gone (the extraction of shouldInvalidateExpression into ScopeOps replaced $this->getNodeKey(...) with an arrow function). Verified by running simple-downgrade 7.4 on the tree: 17 nullsafe remnants in the output before, zero after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
…esettable via attribute
2.2.x now consults hasDefaultValue() in MutatingScope::issetCheck, so the gate-parity alignment that removed nativeHasDefaultValue() from the isset verdict points the other way again: a property with a default value is always set, so isset() on it is true. Also absorb the upstream isset-property-default-value fixture: the no-default coalesce narrows to int here because the === null reads in the guard prove both properties are initialized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b
The rebase kept the stale pre-c2c1497254 shape of the acceptor merge loop: a longer acceptor's raw parameters (possibly ExtendedDummyParameter) were stored and the next acceptor called union() on them. Port the upstream form - wrap every acceptor's parameters in NativeParameterReflection up front and mark length-mismatched positions optional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b
Drop the duplicated alternative-entry harness loop (both worlds added one) and update the new rows to this branch's richer results: the composed nullsafe narrowing also constrains the call expression itself, and the disjunction falsey merge keeps its sure-first description order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b
This branch's eager fold lives on DefaultNarrowingHelper; the copy the rebase carried into ConditionalExpressionHolderHelper was dead and referenced imports this file does not have. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
July 28, 2026 18:27
dc0e107 to
a834c78
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