Skip to content

New world: single-pass expression processing through ExpressionResult callbacks#5838

Draft
ondrejmirtes wants to merge 50 commits into
2.2.xfrom
resolve-type-rewrite
Draft

New world: single-pass expression processing through ExpressionResult callbacks#5838
ondrejmirtes wants to merge 50 commits into
2.2.xfrom
resolve-type-rewrite

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

What this is

The next stage of the ExprHandler refactoring: stop traversing the AST multiple times per expression. Today NodeScopeResolver updates the Scope, MutatingScope::resolveType re-walks the expression for its Type, and TypeSpecifier::specifyTypesInCondition re-walks it again for narrowing — forcing pathologies like BooleanAndHandler::resolveType re-running processExprNode on a throwaway storage just to rebuild a truthy scope it already had.

The new world: after processExprNode finishes, the ExpressionResult carries not just the scope but lazy typeCallback / specifyTypesCallback wired 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) and ResultAwareScope (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() and TypeSpecifier::specifyTypesInCondition() throw unless PHPSTAN_FNSR=0. The old world stays fully functional under PHPSTAN_FNSR=0 (the PHP < 8.1 path until 3.0, where it is mass-deleted together with all resolveType/specifyTypes methods, filterBySpecifiedTypes, filterByTruthy/FalseyValue and the dispatcher).

Working agreements baked into the branch

  • The new world is cut away from the old: callbacks contain copied-and-adjusted code, never delegating to resolveType/specifyTypes. Duplication until 3.0 is accepted.
  • ResultAwareScope only at sanctioned boundaries: extension invocations and ParametersAcceptorSelector (the @api TypeSpecifier::create()/specifyTypesInCondition() with the adapter remain legitimate entry points).
  • Child ExpressionResults are threaded through closures — never fetched from ExpressionResultStorage (storage is the fiber rendezvous only).
  • TDD: every change starts as a failing probe in the temporary NewWorldTypeInferenceTest; each new-world branch gets a probing assert, verified identical in both worlds.
  • No TODO markers in new-world code — genuine dependencies on unmigrated handlers are stated as facts.

Migrated so far

  • Handlers: Scalar, Variable, Assign (incl. conditional-expression holders with a per-entry type resolver; unwrapAssign is 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.
  • Apply side: MutatingScope::applySpecifiedTypes — original types resolved in tiers (extension registry → scope-tracked holders → caller-supplied results → guarded bridge); shares the conditional-holder matching tail with filterBySpecifiedTypes. getTruthyScope/getFalseyScope and per-statement narrowing run on it, so if/else narrowing works end-to-end in the new world.
  • Engine: fiber delivery of whole ExpressionResults (resume at the end of processExprNode), synthetic expressions processed on demand on the plain scope, If_/elseif condition types and processArgs arg types from results, findEarlyTerminatingExpr reordered.

Verification

  • NewWorldTypeInferenceTest (temporary — delete once the whole suite is green under the guard): 33 assertions, green both under the guard and under PHPSTAN_FNSR=0, covering scalars, nested/by-ref assigns, params, extension-driven calls, if/else narrowing ($v = 1; if ($v)), assign-in-condition, function asserts, conditional return types, and holder-driven narrowing ($len = strlen($s); if ($len)$s is non-empty-string).
  • PHPSTAN_FNSR=0 parity 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: BinaryOp equality/comparisons (unlocks dynamic variable names and Ternary/Match holders), BooleanAnd/Or (deletes the flagship re-walk and BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH), Ternary/Coalesce.

🤖 Generated with Claude Code

ondrejmirtes and others added 29 commits June 10, 2026 15:28
…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>
ondrejmirtes and others added 21 commits June 10, 2026 22:57
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>
@ondrejmirtes ondrejmirtes force-pushed the resolve-type-rewrite branch from 2841c32 to f461883 Compare June 11, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant