New world: single-pass expression processing through ExpressionResult callbacks#5838
Draft
ondrejmirtes wants to merge 50 commits into
Draft
New world: single-pass expression processing through ExpressionResult callbacks#5838ondrejmirtes wants to merge 50 commits into
ondrejmirtes wants to merge 50 commits into
Conversation
…OldWorld() MutatingScope::getType()/getNativeType()/getKeepVoidType() and TypeSpecifier::specifyTypesInCondition() throw when the switch returns true (and PHPSTAN_FNSR != 0, PHP 8.1+) — the migration meter for moving type resolution and narrowing onto single-pass ExpressionResult callbacks. The committed state is `return false;`: mixed mode, where migrated handlers run their callbacks, everything else takes the legacy old-world bridges, and the whole test suite stays green throughout the rewrite. A handler-migration leg starts by flipping the literal to true so the guard names whatever still needs the new callbacks.
Carry a lazy typeCallback and specifyTypesCallback on ExpressionResult so an expression's Type and SpecifiedTypes are available after processExprNode finishes, without re-traversing via MutatingScope::getType or TypeSpecifier. - ExpressionResult: getType/getNativeType/getTypeForScope (#5224-style single scope-arg callback), getSpecifiedTypes, and getTruthyScope/getFalseyScope rebuilt on top of it. When a handler has not supplied a callback, getType falls back to $scope->getType (legacy bridge: works under PHPSTAN_FNSR=0, hits the guard under FNSR=1). - Store ExpressionResult per Expr; FiberScope::getType/getNativeType now suspend for the whole ExpressionResult (ExpressionResultForExprRequest, renamed) and resume at the end of processExprNode via storeResult; base storeResult populates storage so findResult works without fibers too. - ImplicitToStringCallHelper takes the resolved Type from the caller's child ExpressionResult; findEarlyTerminatingExpr takes the result type. - Migrate ScalarHandler, VariableHandler and AssignHandler to supply the type callback (Assign reads its RHS type/native-type from the stored result). echo '1' passes at level 8 under the guard; FNSR=0 stays on the legacy path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orld The new world is cut away from the old: typeCallback/specifyTypesCallback carry copied-and-adjusted code, never delegating to resolveType()/ specifyTypes() (which are deleted in 3.0). ResultAwareScope is used only at the sanctioned boundaries: extension invocations and ParametersAcceptorSelector (+ TypeSpecifier conditional-return/assert helpers until ported). - ResultAwareScope: non-suspending adapter for code that receives a Scope mid-analysis. getType() tiers: ExpressionTypeResolverExtensions -> scope- tracked holder -> known child ExpressionResults -> inline re-processing of the (possibly synthetic) expression -> guarded legacy bridge. Derivation-safe (pushInFunctionCall/popInFunctionCall carry the adapter context, mirroring FiberScope); native variant via doNotTreatPhpDocTypesAsCertain override. - TypeSpecifier::specifyTypesInCondition head-checks: ResultAwareScope recursion stays in the new world; FiberScope (rules, e.g. ImpossibleCheckTypeHelper) suspends for the ExpressionResult. - FiberScope: getExpressionResult() extracted; doNotTreatPhpDocTypesAsCertain stays fiber-aware. - ScalarHandler: specifyTypesCallback via DefaultNarrowingHelper (new-world copy of default truthy/falsey narrowing using the expression's own type). - AssignHandler: the processAssignVar callback result carries the assigned value's type (hasTypeCallback() contract, AssignOp wraps with expr only and bridges); nested assigns flow through result delegation (no unwrapAssign on the type path, no storage lookups); specifyTypesForAssign covers null context (RHS result narrowing minus the assigned var) and variable targets (default narrowing with the RHS type); conditional-expression holders gated old-world-only with a TODO. - FuncCallHandler: resolveTypeViaResults/specifyTypesViaResults new-world copies; dynamic name uses the name ExpressionResult; call_user_func/clone synthetics processed inline; getFunctionThrowPoint takes a lazy return-type callback and gives throw-type extensions the adapter; processArgs takes the callable-arg type from the result. - NewWorld::isEnabled() transitional switch; NewWorldTypeInferenceTest (temporary, deleted when the suite is green under the guard) - 13 assertions green in both worlds; FNSR=0 parity verified on stress files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…essionResults MutatingScope::applySpecifiedTypes() is the new-world replacement for filterBySpecifiedTypes(): original (pre-narrowing) types resolve in tiers (extension registry -> scope-tracked holders -> caller-supplied ExpressionResults -> guarded legacy bridge), the conditional-holder matching tail is shared with the old world via an extracted private method. getTruthyScope()/getFalseyScope() and the per-statement createNull narrowing run on it; VariableHandler and the TypeExpr/NativeTypeExpr virtual handlers migrate; FuncCall conditional-return and asserts narrowing are copied into the handler (*ViaResults) instead of delegating to TypeSpecifier internals. Unmigrated handlers bridge in mixed mode: TypeSpecifier::specifyTypesInCondition head-checks route ResultAwareScope/FiberScope back into the new world when the result carries a specifyTypesCallback and fall through to the guarded dispatcher otherwise. ResultAwareScope is the non-suspending adapter for extensions called mid-analysis (self-seeded results prevent is_int()-family recursion; syntheticsInFlight markers guard against pricing cycles).
…iber flush and first-class-callable result expr
The array leg: per-item types are captured at their own evaluation points,
so `[$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c]` infers
`array{1, 2, 1, 3, 1, 2}` — the old world resolves all items on a single
scope and cannot do this. processVirtualAssign takes an optional assigned-type
callback (auto-priced for TypeExpr/NativeTypeExpr); PreInc/PreDec extract a
pure resolveTypeFromVarType() shared by both worlds; PostInc/PostDec type as
the pre-step value.
Engine fixes found by make-phpstan divergence triage:
1. Statement lists no longer flush pending fibers. The flush ran at the end of
every nested list, so a fiber asking about an expression the enclosing
statement still had to process (a loop condition after its body) was
synthetically answered on the stale suspension scope and stored under the
real AST node's key, early-resuming later legitimate askers
(`do { $count++ } while ($count < 3)` reported "0 < 3 always true").
Pending requests now wait for the real storeResult resume; only
analysis-unit boundaries (file, function, method, trait) flush genuine
synthetics.
2. The first-class-callable early path now rewraps its result with the
original expr. It used to carry the virtual *CallableNode, whose
resolveType is intentionally mixed, so `$f = strlen(...)` typed $f as
mixed in both worlds.
NewWorldTypeInferenceTest corpus grown 34 -> 132 assertions, driven by a raw
xdebug coverage audit of all branch changes (PHPUnit per-test coverage misses
data providers): all BinaryOp operators, inc/dec variants, extension probes
(is_int, assert, intdiv throw certainty), assertNativeType, bridge probes for
unmigrated constructs. Coverage of executable changed lines: 47.5%; the rest
classified in NEW_WORLD.md (old-world bodies, defensive throws, rule-driven
paths, future-leg provisions).
nsrt mixed-mode failures 45 -> 31 (0 errors); make phpstan divergences 30 -> 13.
…ed by rule-applied filters Rules narrow their scope after expressions were evaluated — CallMethodsRule filters by a synthetic `$name === 'doFoo'` per possible dynamic method name — and FiberScope::getType() answered from the ExpressionResult's memoized type, ignoring that narrowing (`$this->$name($param)` with name/param correlated via if/else branches reported bogus parameter errors). The answer must also keep the expression's own evaluation-point semantics: in `(new Example)->dump($string1 = 'abc')->dump($string1)` the outer call's visit scope predates the inner assignment, so resolving on the rule's scope is equally wrong. This is what the old fiber design's preprocessScope replay provided. The new-world equivalent: FiberScope accumulates the filterByTruthyValue/ filterByFalseyValue conditions applied since the node visit and getType() replays them onto the result's own scope, resolving there via the new ExpressionResult::getTypeOnScope() — per-scope evaluation where tracked conditional-holder narrowing applies, run on the plain scope variant so the legacy bridges cannot suspend on the same expression again. Also fixes filterByFalseyValue delegating to parent::filterByTruthyValue (copy-paste).
…pVoid through the old world The new-world per-scalar conditional-holder block constructed SpecifiedTypes directly with only the assigned expression's entry. Equality narrowing produces more: `$clazz?->foo !== null` pins $clazz non-null and gives the shortcircuited $clazz->foo its own key — without those holders, `$result = $clazz?->foo; if ($result !== null)` no longer narrowed $clazz (bug-6120). Guarded old-world bridge until the equality migration. FiberScope::getKeepVoidType() falling back to the regular type silently lost the void — regular results store void as null, so "Result of method (void) is used" stopped being reported. Guarded old-world bridge too.
Dynamic return type extensions ask for argument types through the ResultAwareScope adapter, which wrapped whatever scope the type callback received — for memoized asks that is the result's post-call scope, where the call's own virtual mutations already applied. array_shift($this->container) inside `if ($this->container !== [])` typed as string|null because the extension saw the already-shifted (possibly-empty) array. The adapter now wraps the scope captured right after the arguments were processed, before the call's own effects — matching where the old world priced extension reads.
ClassStatementsGatherer collects each node after the inner callback ran, and rules in that callback suspend — their parked fibers defer the collection. ClassPropertiesNode/ClassMethodsNode/ClassConstantsNode snapshot the gathered arrays right after the member list, so they saw incomplete data (a private method called as self::test() inside another method was reported unused). The class statement joins file/function/method/trait as a flush boundary.
The function/method/closure return-statement collectors and ClassStatementsGatherer forwarded each node to the inner callback (rules) first and collected after. Rules suspend their fiber, deferring the collection past the point where FunctionReturnStatementsNode/ MethodReturnStatementsNode/ClosureReturnStatementsNode snapshot the gathered arrays — execution ends and return statements went missing from the aggregates (@param-out "never assigns" false positives, missing reads in class aggregates). Collection is a pure append and cannot suspend, so it now runs before the forward.
…th too The call-point pricing fix promoted the adapter base in resolveTypeViaResults but not in specifyTypesViaResults — native-type narrowing then saw PHPDoc types, so the "type is coming from a PHPDoc" tip disappeared from impossible-check errors (the native answer falsely matched the certain one).
The blocks were gated old-world-only from the era when the corpus had to stay green with the guard exceptions active; in mixed mode they were skipped entirely, losing variable-certainty correlations like `$mode = isset($x) ? "remove" : "add"` implying the existence of $x from the value of $mode. Their internals are guarded old-world bridges, consistent with the unmigrated TernaryHandler/MatchHandler state.
…edTypes A Maybe-certainty holder carries the variable's "when defined" type; using it as the original for sure-not math turned `if ($a)` on a maybe-defined mixed variable into never (falsy-union minus falsy). The original must match getType() semantics, so Maybe holders fall through to the guarded bridge. This also ran under PHPSTAN_FNSR=0 (the engine picks the apply path whenever the result carries callbacks), breaking old-world parity on bug-pr-339.
Two structural changes proven by the NodeScopeResolverTest slowdown hunt (92.8s vs 21.5s on 2.2.x at equal peak memory; per-test times degraded 19→79ms across the run while the base stayed flat; with GC disabled the rewrite ran in 24.4s — the entire 4.3x was cyclic-GC scans over live webs): 1. DefaultNarrowingHelper::specifyDefaultTypes() loses the expression type. It only needed it for nullsafe short-circuiting, and single-pass analysis does not need that: expressions process inside-out, so only the two nullsafe handlers ever see a `?->` — they will emit the plain-chain variant alongside their own key once, and parents compose their results. No recursive chain-walking, and specifyTypesCallbacks no longer invoke the typeCallback (repeatedly computing types just to narrow). 2. FuncCall result callbacks no longer capture the ExpressionResultStorage the result lives in — every stored call result was a reference cycle, and one call anywhere in an expression made the whole ancestor result graph collectable only by the cyclic GC. Late asks build their adapters on a fresh storage; the synthetics-in-flight cycle guard threads through it. NodeScopeResolverTest: 92.8s -> 25.5s (2.2.x base: 21.5s), same failures.
…new world The nullsafe handler is now the only place that knows about `?->` (NEW_WORLD.md §3.10): it evaluates the subject once, narrows it non-null for the property part through the new type-taking ensureShallowNonNullabilityFromTypes(), shows rules the virtual plain fetch itself (storing a result their asks resolve from), and its narrowing callback emits the plain-chain dual key — one structural getNullsafeShortcircuitedExpr call — plus a subject-not-null entry, replacing the old dispatcher-built `BooleanAnd($var !== null, $var->prop)` recursion. The plain handler propagates a nullsafe var's short-circuit null exactly one level; no recursive chain walking anywhere. ExpressionResult gains companionResults: producers attach results for companion expressions their narrowing touches (the plain variant), and applySpecifiedTypes resolves pre-narrowing types from them. FiberScope::getScopeType()/getScopeNativeType() route through the expression result + filter replay until the dedicated scope-walk design lands.
Shares the §3.10 nullsafe narrowing callback with the property handler (extracted to DefaultNarrowingHelper), with a purity gate mirroring TypeSpecifier::create(): impure call results are not remembered, so only the subject-not-null entry survives for them. The call part is reused through the new MethodCallHandler::processCallWithVarResult() seam — the subject is evaluated once, narrowed non-null from its result types, and the threaded $calledOnType also de-guards the plain handler's biggest old-world ask. While the virtual plain call is in flight, rules asking about the subject get a narrowed-view result (the old delegation re-evaluated the subject on the narrowed scope; storing the view and restoring the original preserves that contract without the second evaluation) — except for an always-null subject, which stays null so the call is reported.
The single-pass showcase: the right operand is evaluated on the left-truthy/left-falsey scope during processing, so the typeCallback composes the two child results directly — no processExprNode re-walk on a throwaway storage, no BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH, no flattened-chain fast path in the new world (the old ones stay for PHPSTAN_FNSR=0). A 6-arm boolean chain (depth 5 > the old cap 4) narrows every arm and constant-folds whole-chain asks under the migration meter. - specifyTypesCallback: the old narrowing math with child narrowing from the child ExpressionResults (specifyChildTypes); normalize() and the conditional-holder helpers price narrowing-original asks through ResultAwareScopes seeded per base scope — seeding a result under a different base answers original-type asks with already-narrowed evaluation-point types (is_bool($x) && $x falsey lost the non-bool half). - ExpressionResult::getTruthyScope()/getFalseyScope() consult the handler's scope callbacks first, the specify-callback reconstruction second. BooleanAnd's truthy scope is the right operand's truthy scope (incremental — the left narrowing is already part of it); re-deriving the whole conjunction re-unions per-arm types the old world never unioned and drifts representations (array<mixed> vs array<mixed, mixed>). Migrated handlers pass new-world scope callbacks or none — the legacy filterByTruthyValue($expr) bridges are stripped from Assign, FuncCall, PropertyFetch, both Nullsafe handlers and Variable. - FuncCallHandler::specifyTypesViaResults: the dynamic-name fall-through invokes the old-world body directly (specifyTypesFromCallableCall + default context) — re-dispatching through specifyTypesInCondition bounced an incoming adapter scope back into the seeded self-result forever. - Virtual nodes built by handlers embed toFiberScope() scopes in the new world (BooleanAndNode/BooleanOrNode right scope) so the ConstantCondition rules' getRightScope()->getType() asks resolve through the stored right result. - ResultAwareScope answers Yes-tracked plain variables from the holder — filter-derived adapters lose their context and variables fell through to the guarded bridge; superglobals (Yes-defined, no holder) keep falling through. Corpus: 17 new probes (deep chains, const folds via parent asks, inside-out narrowing, representation pins for both found regressions, statement null-context, Or-in-And falsey, unmigrated-arm fall-through, isset-holder re-derivation, dynamic-name call in condition). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ternary typeCallback composes the branch results — each branch was evaluated on the matching cond-narrowed scope during processing, so the old resolveType's re-processing of the condition on a throwaway storage dies (PHPSTAN_FNSR=0 keeps it). The short ternary asks the condition's type on its own truthy scope via getTypeOnScope, promoting the scope first for native asks. Narrowing rewrites the ternary into the same synthetic the old world used — (cond && if) || (!cond && else) — and processes it through the migrated boolean handlers (unseeded ResultAwareScope, tier 4). BooleanNot: constant folds via the inner result; incremental swapped branch scopes (the truthy scope of !X is X's falsey scope and vice versa); narrowing negates the context onto the inner result, with the old-world dispatcher and an unseeded adapter for not-yet-migrated inner expressions. AssignHandler's Ternary conditional-holder block is unlocked: the cond's narrowing comes from its re-processed result's specify callback and getTruthyScope()/getFalseyScope(), branch types are priced through adapters on the filtered scopes, and projected entry expressions resolve through a resolver mirroring the assign one. FNSR=0 keeps the old block. Found in the process: the nullsafe specify callback never ran the plain call's type-specifying extensions (@phpstan-assert-if-true, bug-12866) — the old synthetic BooleanAnd(var !== null, plainCall) dispatch provided that. Exposed by BooleanNot's incremental falsey scope being the nullsafe truthy scope. createNullsafeSpecifyCallback now composes the plain call's narrowing through the dispatcher when the chain executed, until MethodCallHandler's narrowing migrates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…th ExpressionResult asks TDD'd under disableOldWorld=true with a statement exerciser covering if/elseif/else, while (incl. always-true), do-while, for, foreach, switch, const, unset and @var annotations — all green under the guard and byte-identical under PHPSTAN_FNSR=0. - If/elseif: the next-arm scope is the elseif cond result's falsey scope. - While: before-cond and last-pass cond booleans come from the cond results; the loop-exit scope goes through the new filterByFalseyValueUsingResult() (apply path for migrated conds, guarded filterByFalseyValue otherwise). - Do-while: the condition is processed once, hoisted above the always-iterates check and the DoWhileLoopConditionNode callback; the single result feeds the boolean, the falsey scope and the points. - For: the last-cond result is captured (was discarded) and feeds always-iterates and post-loop falsey filtering; the count-pattern asks in inferForLoopExpressions are priced through adapters. - Foreach: getForeachIterateeTypes() computes the iteratee PHPDoc and native type pair per originalScope and threads it through the enterForeach helper, the constant-array unroll, and the new MutatingScope::enterForeach/enterForeachKey signatures (the methods no longer ask for types; no external callers existed). Post-loop dim-fetch/key/value re-asks go through per-scope adapters; the traversable throw point takes the iteratee type. - Switch: exhaustiveness asks the cond result on the case-narrowed scope via getTypeOnScope. - Const_/ClassConst take the value result's types; the Unset_ dim-var, findEarlyTerminatingExpr called-on types, processStmtVarAnnotation and execution-end never-checks, and AssignHandler's by-ref array keys go through adapters. - MethodThrowPointHelper takes a lazy return-type callback (FuncCallHandler's shape); MethodCall/StaticCall pass none yet, keeping the guarded bridge until their migrations. - MutatingScope::specifyExpressionType's ArrayDimFetch parent-update reads dim/var/native types holder-first (getTypeFromTrackedHolder) — Yes-tracked expressions answer from their holders without the guarded resolveType walk. - LiteralArrayItem embeds fiber scopes in the new world so rules' asks about item keys resolve through stored results — the virtual-node scope-embedding rule generalized from BooleanAndNode/BooleanOrNode. - ThrowHandler migrated en passant (constant never type, inner result's type for the throw point). Remaining raw asks are documented residue: rule callbacks already receive FiberScope, FNSR=0 branches, the recursive by-ref closure-use self-ask (ClosureHandler leg), the by-ref args fallback, the createCallableParameters type callbacks (priced by callers), and filterBy* over engine synthetics (the equality leg). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The resolveType body was already guard-safe (literals, holder-tracked runtime constants, ConstantResolver) — the typeCallback is its copy; narrowing is the type-free default. Unblocks true/false/null literals in every later migration meter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The InitializerExprTypeResolver getTypeCallback asks the child's ExpressionResult first (NEW_WORLD.md paragraph 3.12), bridging only for expressions it has no result for; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same shape as UnaryMinusHandler: the InitializerExprTypeResolver getTypeCallback asks the child's ExpressionResult first (NEW_WORLD.md paragraph 3.12); narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same shape as UnaryMinusHandler: the InitializerExprTypeResolver getTypeCallback asks the child's ExpressionResult first (NEW_WORLD.md paragraph 3.12); narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
print always evaluates to 1 — the typeCallback is the constant; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback intersects the inner result's type with object and maps it through CloneTypeTraverser — the resolveType copy with the result swap; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
exit()/die() never produce a value — the typeCallback is the constant NonAcceptingNeverType; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ is transparent: the type, narrowing, and branch scopes all delegate to the suppressed expression's ExpressionResult; a not-yet-migrated inner takes the old-world dispatcher with an unseeded adapter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
eval() evaluates to mixed — constant typeCallback, default narrowing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
include/require evaluate to mixed — constant typeCallback, default narrowing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cast type goes through the results-first InitializerExprTypeResolver callback (NEW_WORLD.md paragraph 3.12); narrowing keeps the old "!= ''" synthetic, processed through the migrated handlers on demand via an unseeded ResultAwareScope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cast type goes through the results-first InitializerExprTypeResolver callback (NEW_WORLD.md paragraph 3.12), with the Unset_ cast as a constant null; bool/int/double cast narrowing keeps the old comparison synthetics, processed through the migrated handlers on demand via an unseeded ResultAwareScope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback goes through the results-first InitializerExprTypeResolver callback (NEW_WORLD.md paragraph 3.12) with the dynamic class expression answered by its ExpressionResult; dynamic constant names stay mixed; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each part's type is captured at its own evaluation point in the sequence (per-part ExpressionResults keyed by spl_object_id, the ArrayHandler pattern) and folded through resolveConcatType; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback folds the check from the target and class-expression results; the specifyTypesCallback is the old narrowing math with TypeSpecifier::create() resolving its null/purity gates through an adapter seeded with the target and class results (the FuncCall self-seeding precedent). instanceof arms now compose through the migrated boolean and ternary handlers under the migration meter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The equality milestone. The specifyTypesCallback invokes the old specifyTypes() body directly with an unseeded ResultAwareScope — the ~1300 lines of equality/comparison narrowing (EqualityTypeSpecifying- Helper, count/strlen/preg_match patterns, range inference) stay a single source instead of a copied dual-maintenance burden; re-entering through the specifyTypesInCondition dispatcher would bounce off the head-check back into the callback. Inner synthetics (BooleanNot(Identical), swapped comparisons) route through the migrated handlers via the adapter's synthetic processing. The 3.0 cleanup absorbs the body into the callback. - The Identical/NotIdentical typeCallback bridge is gone: RicherScopeGetTypeHelper gains Type-taking variants (getIdenticalResultFromTypes/getNotIdenticalResultFromTypes) fed from the operand results. - The BinaryOp result carries its operands as companionResults so applySpecifiedTypes can price narrowing originals (e.g. the count() call in count($x) > 0). - resolveOriginalTypesForApply is restructured into nullable tiers and gains a synthetic-dim-fetch tier: the original of a narrowing-built $list[$index] entry derives from the resolvable var and dim types (plain non-null arrays; ArrayAccess and nullsafe chains bridge). - FiberScope::getKeepVoidType drops its guarded bridge: the keep-void re-ask uses the attributed clone, which is a synthetic expression the fiber machinery already processes on demand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback composes the var and dim results: getOffsetValueType for arrays, the offsetGet() synthetic through an unseeded adapter for ArrayAccess, one-level nullsafe short-circuit propagation per NEW_WORLD.md paragraph 3.10. The write-context $x[] form types as never; narrowing is the type-free default. MutatingScope's holder-first helpers gain a scalar tier — the ArrayDimFetch parent-update in specifyExpressionType resolves literal dims context-free instead of through the guarded bridge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback runs issetCheck() on an unseeded adapter so its expression walk resolves through ResultAwareScope tiers; the specifyTypesCallback invokes the old specifyTypes() body directly with an unseeded adapter (the BinaryOp precedent) — the multi-isset And-chain synthetic routes through the migrated handlers. NonNullabilityHelper::ensureNonNullability gains an askScopeFactory: the pre-processing non-nullability walk prices its type asks through an adapter while specifying on the real evolving scope; null keeps the guarded direct asks (PHPSTAN_FNSR=0). All three callers (Isset, Empty, Coalesce) pass the factory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback runs issetCheck() on an unseeded adapter; the specifyTypesCallback invokes the old specifyTypes() body directly — its "!isset(X) || !X" synthetic routes through the migrated handlers via the adapter's synthetic processing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The isset(left) synthetic is processed once through the migrated IssetHandler: its truthy narrowing is applied for the after-scope, and the right-side scope comes from the coalesce's OWN falsey narrowing (left narrowed to null when isset-certain) — the isset falsey would unset the left expression and poison its certainty (falsey-coalesce regression caught by nsrt). The typeCallback runs issetCheck on an unseeded adapter and reads the left's type on the isset-truthy scope via getTypeOnScope; the specifyTypesCallback is the old body with the right side answered by its result. Everything is UNSEEDED: the left result was evaluated on the non-nullability-ensured scope, so its memoized type is already null-stripped — seeding it as an apply companion or adapter entry poisoned narrowing originals and create()'s null gates (the isset-coalesce-empty-type regression; paragraph 3.13's evaluation-base rule re-confirmed). applySpecifiedTypes originals gain a trivial TypeExpr tier (the type is the node's payload). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback mirrors PropertyFetchHandler: the native-promoted path goes through the property reflection finder, the class expression is answered by its ExpressionResult, a nullsafe class expression short-circuits one level (NEW_WORLD.md paragraph 3.10), and dynamic property names keep the guarded bridge. Narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First-class callables are produced before handler dispatch, so the result callbacks live in the fast-path: types resolve from reflection (getFirstClassCallableType/createFirstClassCallable) with the two scope asks — dynamic function name, method receiver — priced through unseeded adapters; narrowing is empty (a first-class callable is always a truthy Closure). processArgs now carries the per-argument ExpressionResults as companionResults on its result (with callback-less context-scope wrappers for closure and arrow-function arguments) so call handlers can seed their adapters — a passed closure's memoized type carries the parameter-type inference context that re-processing outside the call would lose. Covers FirstClassCallableFuncCall/MethodCall/New/StaticCall handlers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback resolves the call via resolveMethodCallTypeViaResults: the receiver comes from its ExpressionResult (one-level nullsafe short-circuit per NEW_WORLD.md paragraph 3.10), args and dynamic-return extensions resolve through an adapter seeded with the call's self-result (extension helpers re-asking about this call terminate — the FuncCall precedent) and the per-arg companion results (passed closures answer with their context-aware memo); dynamic method names bridge. The specifyTypesCallback invokes the old specifyTypes() body directly with an unseeded adapter (the BinaryOp precedent). The lazy return-type callback is threaded into MethodThrowPointHelper, removing the guarded explicit-never and implicit-throws asks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mirror of MethodCallHandler: late-static-binding name resolution stays scope-context-based, the class expression comes from its ExpressionResult (one-level nullsafe short-circuit), args and extensions resolve through the self-seeded adapter with per-arg companions, the old specifyTypes() body runs directly with an unseeded adapter, and the lazy return-type callback feeds the throw-point helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typeCallback runs exactInstantiation (constructor template inference, parent-construct chains) on an adapter seeded with the per-arg companion results; anonymous classes resolve via reflection and dynamic class expressions through the adapter. The pre-args constructor selection prices its asks through adapters as well. The specifyTypesCallback invokes the old body with an unseeded adapter. Two memory lessons baked in: - adapters created per instantiation use FRESH storages — synthetic processing duplicates the adapter's storage, and duplicating the live per-file storage is O(file) (measured +38 MB peak over a 30-file directory, enough to OOM 599M workers); - processArgs retains only the closure/arrow-function context wrappers as companions, not every argument's full result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The yield expression evaluates to the enclosing generator's TSend — scope-context reads only; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
yield from evaluates to the inner generator's TReturn, extracted from the inner expression's ExpressionResult; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pipe operator is processed as its rewritten call — the result delegates type and narrowing to the call's ExpressionResult. The FuncCallHandler ask for invokable-object names is de-guarded along the way (the name result when available, an adapter otherwise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2841c32 to
f461883
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.
What this is
The next stage of the ExprHandler refactoring: stop traversing the AST multiple times per expression. Today
NodeScopeResolverupdates the Scope,MutatingScope::resolveTypere-walks the expression for itsType, andTypeSpecifier::specifyTypesInConditionre-walks it again for narrowing — forcing pathologies likeBooleanAndHandler::resolveTypere-runningprocessExprNodeon a throwaway storage just to rebuild a truthy scope it already had.The new world: after
processExprNodefinishes, theExpressionResultcarries not just the scope but lazytypeCallback/specifyTypesCallbackwired by the handler at the moment it had its children's results and the correct intermediate scopes in hand. Rules and extensions get answers through two adapters:FiberScope(rule node-callbacks suspend until the result exists) andResultAwareScope(extensions invoked mid-analysis never suspend — children are already processed; synthetics are processed inline).Full design doc with motivations, settled decisions, inventory and status log:
NEW_WORLD.md(branch-lifetime document).Enforcement
Scope::getType()/getNativeType()/getKeepVoidType()andTypeSpecifier::specifyTypesInCondition()throw unlessPHPSTAN_FNSR=0. The old world stays fully functional underPHPSTAN_FNSR=0(the PHP < 8.1 path until 3.0, where it is mass-deleted together with allresolveType/specifyTypesmethods,filterBySpecifiedTypes,filterByTruthy/FalseyValueand the dispatcher).Working agreements baked into the branch
resolveType/specifyTypes. Duplication until 3.0 is accepted.ResultAwareScopeonly at sanctioned boundaries: extension invocations andParametersAcceptorSelector(the@apiTypeSpecifier::create()/specifyTypesInCondition()with the adapter remain legitimate entry points).ExpressionResults are threaded through closures — never fetched fromExpressionResultStorage(storage is the fiber rendezvous only).NewWorldTypeInferenceTest; each new-world branch gets a probing assert, verified identical in both worlds.Migrated so far
Scalar,Variable,Assign(incl. conditional-expression holders with a per-entry type resolver;unwrapAssignis no longer needed on the type path — nested assigns flow through result delegation),FuncCall(return type extensions,selectFromArgs, conditional return types,@phpstan-assert— the latter two copied in as*ViaResults),TypeExpr/NativeTypeExpr.MutatingScope::applySpecifiedTypes— original types resolved in tiers (extension registry → scope-tracked holders → caller-supplied results → guarded bridge); shares the conditional-holder matching tail withfilterBySpecifiedTypes.getTruthyScope/getFalseyScopeand per-statement narrowing run on it, soif/elsenarrowing works end-to-end in the new world.ExpressionResults (resume at the end ofprocessExprNode), synthetic expressions processed on demand on the plain scope,If_/elseif condition types andprocessArgsarg types from results,findEarlyTerminatingExprreordered.Verification
NewWorldTypeInferenceTest(temporary — delete once the whole suite is green under the guard): 33 assertions, green both under the guard and underPHPSTAN_FNSR=0, covering scalars, nested/by-ref assigns, params, extension-driven calls,if/elsenarrowing ($v = 1; if ($v)), assign-in-condition, function asserts, conditional return types, and holder-driven narrowing ($len = strlen($s); if ($len)→$sisnon-empty-string).PHPSTAN_FNSR=0parity against the pre-branch baseline verified on stress files (try/catch, closures,foreach,match, properties, AssignOp).Intentionally red
The pre-existing test suite is red under the guard until the rewrite completes — that is the migration pressure by design. Next natural handlers:
BinaryOpequality/comparisons (unlocks dynamic variable names and Ternary/Match holders),BooleanAnd/Or(deletes the flagship re-walk andBOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH),Ternary/Coalesce.🤖 Generated with Claude Code