Skip to content

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

Open
ondrejmirtes wants to merge 22 commits into
2.2.xfrom
resolve-type-rewrite-2
Open

Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857
ondrejmirtes wants to merge 22 commits into
2.2.xfrom
resolve-type-rewrite-2

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jun 12, 2026

Copy link
Copy Markdown
Member

Groundwork for the "new world" where an expression is traversed once: after processExpr, its ExpressionResult knows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementing TypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, the TypeSpecifier dispatcher) are guarded behind NewWorld::disableOldWorld() and get mass-deleted in PHPStan 3.0.

What's on the branch, bottom up:

  • Guards + ExpressionResultFactory: old-world type resolution entry points throw when NewWorld::disableOldWorld() is flipped (the migration meter); all ExpressionResult construction goes through a generated factory.
  • ExpressionResult carries beforeScope, expr, typeCallback, specifyTypesCallback and is stored per node in ExpressionResultStorage (layered O(1) duplicate()), replacing the stored before-Scope.
  • ExprHandler / TypeResolvingExprHandler split: resolveType/specifyTypes move to the sub-interface so handlers can shed them one by one.
  • ExpressionResultStorageStack: old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers' resolveType) keep working for converted handlers' nodes. Every scope shares the stack created by its internal scope factory; NodeScopeResolver pushes the storage of the analysis in progress through MutatingScope::pushExpressionResultStorage() (always popped in finally, throwing on imbalance), and MutatingScope answers from the stored result - or processes a synthetic node on demand. Scopes never reference a storage directly, so nothing pins the result graph with the cycle collector disabled in bin/phpstan. Also adds MutatingScope::applySpecifiedTypes - filterBySpecifiedTypes without Scope::getType().
  • First two migrations: ScalarHandler and ArrayHandler no longer implement TypeResolvingExprHandler. The array migration is a precision win the old world cannot reach: each item type is captured at its own evaluation point, so [$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c] infers array{1, 2, 1, 3, 1, 2}.

Verified: full test suite green, make phpstan clean, and analysis memory back at baseline (no leak from the result graph despite gc_disable()).

Closes phpstan/phpstan#13944
Closes phpstan/phpstan#12207
Closes phpstan/phpstan#7155

Closes phpstan/phpstan#2032

Closes phpstan/phpstan#10786

🤖 Generated with Claude Code

ondrejmirtes and others added 10 commits June 12, 2026 19:15
Not all ExprHandlers will be TypeResolvingExprHandler coming into the future.
Instead of resolveType+specifyTypes, they will pass callbacks
into ExpressionResult doing similar job.
…esults

Old-world consumers (TypeSpecifier dispatcher, extensions, rules below
PHP 8.1, unconverted handlers' resolveType) keep working for nodes whose
handler no longer implements TypeResolvingExprHandler: every scope shares
the ExpressionResultStorageStack created by its internal scope factory,
NodeScopeResolver pushes the storage of the analysis in progress through
MutatingScope::pushExpressionResultStorage() (always popped in finally),
and MutatingScope resolves such nodes from the stored result - or by
processing a synthetic node on demand.

Also adds MutatingScope::applySpecifiedTypes (filterBySpecifiedTypes
without Scope::getType()) and the specifyTypesCallback slot on
ExpressionResult consulted by getTruthyScope()/getFalseyScope().

The cycle collector is disabled in bin/phpstan - scopes deliberately
never reference a storage directly, only the stack. Popping severs the
stack->storage edge when an analysis ends, so retained scopes do not pin
the whole result graph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each item type is captured at its own evaluation point in the sequence,
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.

Until the item handlers (BinaryOp, inc/dec, Assign) migrate themselves,
the items resolve as their own results' before-scope evaluation instead
of cascading getTypeForScope(). Narrowing stays on the fallback path -
it is identical to the removed specifyDefaultTypes body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unbalanced push/pop is the one way the ambient storage design can
still be misused - fail immediately instead of silently answering later
type questions from the wrong storage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ondrejmirtes ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from ebb19a0 to 457689b Compare June 12, 2026 17:15
ondrejmirtes and others added 5 commits June 12, 2026 19:19
…sionResults

Both handlers stop implementing TypeResolvingExprHandler and wire
typeCallback + specifyTypesCallback, so their truthy/falsey scopes flow
through MutatingScope::applySpecifiedTypes - the first real exercise of
the new-world narrowing machinery.

The old-world TypeSpecifier dispatcher answers nodes of converted
handlers from the stored ExpressionResult
(MutatingScope::specifyTypesOfNewWorldHandlerNode), processes synthetic
nodes on demand (the 'foo' === $a::class rewrite builds synthetic
Instanceof_ nodes), and falls back to specifyDefaultTypes when the
result carries no callback - which is exactly what such handlers used
to implement.

Exercising the machinery surfaced two gaps in applySpecifiedTypes:

- Expressions not tracked in the scope lost their sureNot narrowing
  ($var->name instanceof Identifier ? ... : ... stopped narrowing the
  else branch). The current type to intersect with or subtract from is
  now priced from the stored ExpressionResult
  (getCurrentTypesOfSpecifiedExpr) instead of Scope::getType().
- Only Yes-certainty holders hold the current type of an expression.
  A Maybe-certainty holder holds the when-defined type (falsy after an
  or-merge in nsrt/bug-pr-339.php), which the certainty-aware
  Scope::getType() never returned - narrowing against it produced
  NeverType and mis-fired conditional expression holders.

AssignHandler's placeholder result for an assignment target now carries
VariableHandler::createTypeCallback - every stored result for a
converted handler's node type must answer type questions itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes phpstan/phpstan#2032

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
return $this->withFlavor(false);
}

private function withFlavor(bool $fiber): self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this read withFiber?

ondrejmirtes and others added 6 commits June 12, 2026 20:07
A sure specification (e.g. is_string($a)) can only hold for a defined
variable, so it still makes the variable defined inside the branch -
one error at the test site, no cascade. Removing a type from a
certainly-undefined variable proves nothing about its definedness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…:create()

How a type constraint on a node translates into narrowing entries is the
producing handler's knowledge, declared on its ExpressionResult - never
re-derived by unwrapping the AST elsewhere.

DefaultNarrowingHelper (recreated from the first rewrite attempt) carries
the default boolean-context narrowing (one sureNot entry, no type ask, no
nullsafe chain-walking) and createSubjectTypes(): ask the subject result's
createTypesCallback, fall back to a single sure/sureNot entry for the
subject node. No purity gates, no structural unwrapping.

AssignHandler fans a type constraint out to the assigned variable and the
assigned expression - nested assignments compose through the assigned
expression's own result, which is what will delete unwrapAssign.
CoalesceHandler delegates to its left side when the type rules the right
side in or out, so ($e ?? null) instanceof Foo narrows $e.

AssignHandler also wires specifyTypesCallback: the assigned variable
narrows by the boolean outcome, plus the $arr[$key] inference after
$key = array_key_first/array_key_last/array_search/array_find_key. The
null-context inferences stay in the old-world specifyTypes() - result-based
asks are always truthy or falsey.

specifyTypesCallbacks no longer touch TypeSpecifier: VariableHandler uses
DefaultNarrowingHelper, InstanceofHandler narrows its subject through
createSubjectTypes(). TypeSpecifierTest's assign-in-instanceof expectations
hold unchanged - the new channel reproduces create()'s emission exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ingExprHandler

The composite handlers wire typeCallback and specifyTypesCallback composed
from their operands' results. This deletes the founding pathology of the
rewrite: BooleanAndHandler::resolveType's re-walk of the left operand on a
throwaway storage, BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH and both
flattened-chain code paths - deep chains compose through nested results.

Child narrowing flows through DefaultNarrowingHelper::getChildSpecifiedTypes():
the child result's specifyTypesCallback first, bridged through the old-world
dispatcher for unmigrated children (the dispatcher answers converted handlers
from stored results, so the bridge terminates; it dies in 3.0). The ternary
still rewrites itself as (cond && if) || (!cond && else) - the synthetic
takes the on-demand path where its real subnodes answer from stored results.

Lessons the conversion forced out of the engine:

- A handler must never ask the scope about its own node mid-processing -
  no stored result exists yet, so the ask takes the on-demand path and
  recurses infinitely (CoalesceHandler's filterByFalseyValue($expr) for
  the right-side scope hung the suite). The equivalent narrowing is built
  directly from the left result instead.
- Composite typeCallbacks evaluate later operands on their captured
  processing scopes. Re-filtering the asking scope loses the left side's
  side effects (by-ref writes, inline assignments); the child result's own
  point breaks synthetic compositions (min()'s $a < $b ? $a : $b reuses
  stored results of the real arg nodes, predating the synthetic's branch
  narrowing). The captured scope has both; native asks flavor it with
  doNotTreatPhpDocTypesAsCertain().
- ExpressionResult::getType()/getNativeType()/getTypeForScope() consult
  tracked expression holders before the typeCallback, mirroring the early
  return in MutatingScope::resolveType() - that is how the nullsafe
  handlers' ensured non-nullability of ($x ?? null) reaches type asks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…perties

Closes phpstan/phpstan#10786

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine never processes on the rule-facing FiberScope - its type asks
suspend, which crashes outside a fiber. One can reach processExprNode
through a stored result's memoized truthy/falsey scope (first computed
inside a rule fiber) consumed by a composite handler for a child's
processing scope: phpstan-phpunit's assertEmpty() extension builds a
synthetic BooleanOr, the converted BooleanOrHandler processes it on
demand, and the right arm's scope comes from the left arm's stored
result. Convert to the mutating flavor at the processExprNode boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ondrejmirtes ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 8eeba94 to be672de Compare June 12, 2026 22:21
Replaces the blanket processExprNode conversion with the root cause: the
hooks are the boundary between the rule-facing world and the engine.
Rules hold FiberScopes and feed them straight into the old-world
dispatcher (ImpossibleCheckTypeHelper passes the rule's scope to
specifyTypesInCondition, phpstan-phpunit's assert extension builds a
synthetic BooleanOr there), so resolveTypeOfNewWorldHandlerNode() and
specifyTypesOfNewWorldHandlerNode() can run with $this being a
FiberScope. They now call toMutatingScope() - identity on a plain scope,
a state-preserving copy on a FiberScope - before invoking result
callbacks and on-demand processing.

Without the conversion the engine processes synthetic nodes on the
rule-facing scope, whose type asks suspend: wasteful inside a rule
fiber, fatal outside one ("Cannot suspend outside of a fiber"). Found
by an ExpressionResult creation tripwire after the CI-only crash never
reproduced locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants