From 83f3b7a31aba7d3f161edae4447dff9dac11d1a8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 23 Aug 2026 21:38:25 +0200 Subject: [PATCH 1/3] Add ExpressionResultStorage::mergeResults() Adopts every before-scope the other storage stored, overwriting existing entries - a finished convergence pass's stores answer for the final walk its replay replaces. Mirrored in the native extension. --- src/Analyser/ExpressionResultStorage.php | 13 ++++++++++++ turbo-ext/src/ExpressionResultStorage.cpp | 26 +++++++++++++++++++++++ turbo-ext/tests/smoke.php | 6 ++++++ 3 files changed, 45 insertions(+) diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index 01db5226c37..280a1a54915 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -41,4 +41,17 @@ public function findBeforeScope(Expr $expr): ?Scope return $this->scopesById[spl_object_id($expr)] ?? null; } + /** + * Adopts every before-scope the other storage stored - a finished + * convergence pass's stores answer for the final walk its replay + * replaces. + */ + public function mergeResults(self $other): void + { + foreach ($other->exprsById as $id => $expr) { + $this->exprsById[$id] = $expr; + $this->scopesById[$id] = $other->scopesById[$id]; + } + } + } diff --git a/turbo-ext/src/ExpressionResultStorage.cpp b/turbo-ext/src/ExpressionResultStorage.cpp index e73cd38835b..2f73a1efa39 100644 --- a/turbo-ext/src/ExpressionResultStorage.cpp +++ b/turbo-ext/src/ExpressionResultStorage.cpp @@ -58,6 +58,24 @@ class ExpressionResultStorage return zv::Val::copyOf(found); } + void mergeResults(zval *other) + { + zv::ObjRef dst(self); + zv::ObjRef src(other); + zv::ArrRef dstExprs(dst.propAt(PT_ERS_PROP_EXPRS).raw()); + zv::ArrRef dstScopes(dst.propAt(PT_ERS_PROP_SCOPES).raw()); + zv::ArrRef srcScopes(src.propAt(PT_ERS_PROP_SCOPES).raw()); + zend_ulong id; + zval *exprZv; + ZEND_HASH_FOREACH_NUM_KEY_VAL(Z_ARRVAL_P(src.propAt(PT_ERS_PROP_EXPRS).raw()), id, exprZv) { + dstExprs.setIndex(id, zv::Ref(exprZv)); + zv::Ref scopeRef = srcScopes.findIndex(id); + if (scopeRef.raw() != NULL) { + dstScopes.setIndex(id, scopeRef); + } + } ZEND_HASH_FOREACH_END(); + } + private: zval *self; }; @@ -104,6 +122,14 @@ void pt_register_expression_result_storage() ExpressionResultStorage(ZEND_THIS).findBeforeScope(expr).intoReturnValue(return_value); }); + cls.method("mergeResults", reg::Public, 1, { reg::any("other") }, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *other; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJECT(other) + ZEND_PARSE_PARAMETERS_END(); + ExpressionResultStorage(ZEND_THIS).mergeResults(other); + }); + cls.register_(); } diff --git a/turbo-ext/tests/smoke.php b/turbo-ext/tests/smoke.php index 53c0a571dd9..fa3f58aff99 100644 --- a/turbo-ext/tests/smoke.php +++ b/turbo-ext/tests/smoke.php @@ -327,6 +327,12 @@ function check(bool $cond, string $msg): void $storage->storeBeforeScope($exprB, $scopeB); check($duplicate->findBeforeScope($exprB) === $scopeA, "ERS $label: original stores do not leak into the duplicate"); + $exprC = new \PhpParser\Node\Expr\Variable('c'); + $duplicate->storeBeforeScope($exprC, $scopeB); + $storage->mergeResults($duplicate); + check($storage->findBeforeScope($exprB) === $scopeA, "ERS $label: mergeResults overwrites with the other storage's entry"); + check($storage->findBeforeScope($exprC) === $scopeB, "ERS $label: mergeResults adopts new entries"); + check($duplicate->findBeforeScope($exprA) === $scopeB, "ERS $label: mergeResults leaves the source untouched"); } // ---- ScopeOps::mergeVariableHolders differingKeys ---- From 31a36041d597fdfb1132f5ef50f5127c10ad65a1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 23 Aug 2026 21:45:51 +0200 Subject: [PATCH 2/3] Replay the recorded fixpoint pass instead of repeating the final loop walk Both convergence break paths guarantee the final walk's entry scope equals the last pass's entry, making the final walk an exact repetition of that pass. Convergence passes now record their (node, scope) emissions (RecordingNodeCallback); when the recorded pass's entry is the fixpoint, the final walk is replaced by replaying the recording through the real callback and adopting the pass's storage and statement result. The replay binds the merged storage for its whole duration, so the recorded scopes answer rule asks from the stored before-scopes the same way the repeated walk's per-emission binding would. Applies to While, Do-While, Foreach (non-unrolled finals) and the by-ref closure convergence, whose replay runs through the gathering callback on the pass's own entry scope - the gathering filter compares the anonymous-function reflection by identity. A body containing constructs that analyse differently at deep context than in the top-level final (nested loop/label fixpoints run only at top level, statement-level classes are skipped at deep context) is not replayed; For keeps its real final walk (it applies inferForLoopExpressions there, which the passes do not). Do-While replays the body only - its condition walks stay real. (adapted from commit e0a32b74bffbaee6624b7d73e3613e94de1aa7a4) --- src/Analyser/NodeScopeResolver.php | 128 +++++++++++++++++++- src/Analyser/RecordingNodeCallback.php | 44 +++++++ src/Analyser/StmtHandler/DoWhileHandler.php | 32 ++++- src/Analyser/StmtHandler/ForeachHandler.php | 35 +++++- src/Analyser/StmtHandler/WhileHandler.php | 40 +++++- 5 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 src/Analyser/RecordingNodeCallback.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e0e8c9bcd00..95bdb58e739 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -106,6 +106,7 @@ use function array_values; use function count; use function in_array; +use function is_array; use function is_int; use function is_string; use function max; @@ -913,6 +914,91 @@ public function getAssignedVariables(Expr $expr): array return []; } + private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody'; + + /** + * Whether a recorded convergence pass over the loop body can replace the + * final walk. A pass runs at deep statement context, the final walk at top + * level - constructs that analyse differently between the two (nested + * loop/label fixpoints run only at top level, statement-level classes are + * skipped at deep context) disqualify the body. Closure bodies process + * context-independently and are not traversed. + * + * @param Node\Stmt[] $bodyStmts + */ + public function isReplayableConvergenceBody(Node $loopNode, array $bodyStmts): bool + { + $cached = $loopNode->getAttribute(self::REPLAYABLE_BODY_ATTRIBUTE); + if ($cached !== null) { + return $cached; + } + + $replayable = true; + foreach ($bodyStmts as $bodyStmt) { + if ($this->hasContextSensitiveConstruct($bodyStmt)) { + $replayable = false; + break; + } + } + $loopNode->setAttribute(self::REPLAYABLE_BODY_ATTRIBUTE, $replayable); + + return $replayable; + } + + private function hasContextSensitiveConstruct(Node $node): bool + { + if ($node instanceof Expr\Closure) { + return false; + } + if ( + $node instanceof Node\Stmt\While_ + || $node instanceof Node\Stmt\Do_ + || $node instanceof Node\Stmt\For_ + || $node instanceof Foreach_ + || $node instanceof Node\Stmt\Label + || $node instanceof Node\Stmt\ClassLike + ) { + return true; + } + + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + if ($this->hasContextSensitiveConstruct($subNode)) { + return true; + } + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if ($item instanceof Node && $this->hasContextSensitiveConstruct($item)) { + return true; + } + } + } + } + + return false; + } + + /** + * Replays a recorded convergence pass's emissions through the real node + * callback in place of the final loop walk. The pass's storage was merged + * into $storage by the caller; binding it for the whole replay lets the + * recorded scopes answer rule asks from the stored before-scopes, the + * same way the repeated walk's per-emission binding would. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function replayRecording(RecordingNodeCallback $recording, callable $nodeCallback, ExpressionResultStorage $storage): void + { + $stack = $this->getExpressionResultStorageStack(); + $stack->push($storage); + try { + $recording->replayThrough($nodeCallback); + } finally { + $stack->pop(); + } + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -955,6 +1041,13 @@ public function callNodeCallback( return; } + if ($nodeCallback instanceof RecordingNodeCallback) { + // recording never asks about types - the pairs are wrapped and + // bound to the storage at replay time instead + $nodeCallback($node, $scope); + return; + } + // post-order emission means the node's own result and every subnode // result are already stored when the callback fires - NodeCallbackScope // answers every ask synchronously from the storage; the emitting @@ -1126,15 +1219,29 @@ public function processClosureNode( $count = 0; $closureResultScope = null; + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $replayEntryScope = null; + $bodyIsReplayable = $this->isReplayableConvergenceBody($expr, $expr->stmts); do { $prevScope = $closureScope; $storage = $originalStorage->duplicate(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); // deep context, like the loop handlers' own convergence passes: inner // loops walk single-pass here and only the final walk below (top-level) // runs their full convergence - otherwise every closure-convergence // pass would re-converge every inner loop from scratch - $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createDeep()); + $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep()); + // the candidate to replace the final walk when this pass's entry + // turns out to be the fixpoint + if ($bodyRecording instanceof RecordingNodeCallback) { + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $intermediaryClosureScopeResult; + $replayEntryScope = $prevScope; + } $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); @@ -1162,7 +1269,24 @@ public function processClosureNode( } $storage = $originalStorage; - $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); + if ( + $replayBodyRecording !== null && $replayPassStorage !== null + && $replayPassResult !== null && $replayEntryScope !== null + && $closureScope->equals($replayEntryScope) + ) { + // the final walk would repeat the recorded fixpoint pass exactly + // (same entry scope, deterministic walk) - adopt the pass's result + // and replay its emissions through the gathering callback instead. + // The pass's own entry scope takes over: the recorded pairs carry + // its anonymous-function reflection, which the gathering filter + // compares by identity (the state is equals-identical anyway). + $closureScope = $replayEntryScope; + $originalStorage->mergeResults($replayPassStorage); + $this->replayRecording($replayBodyRecording, $closureStmtsCallback, $originalStorage); + $statementResult = $replayPassResult; + } else { + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); + } $publicStatementResult = $statementResult->toPublic(); $closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( diff --git a/src/Analyser/RecordingNodeCallback.php b/src/Analyser/RecordingNodeCallback.php new file mode 100644 index 00000000000..be4641fe2d5 --- /dev/null +++ b/src/Analyser/RecordingNodeCallback.php @@ -0,0 +1,44 @@ + */ + private array $pairs = []; + + public function __invoke(Node $node, Scope $scope): void + { + $this->pairs[] = [$node, $scope]; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function replayThrough(callable $nodeCallback): void + { + foreach ($this->pairs as [$node, $scope]) { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $nodeCallback($node, $scope->toNodeCallbackScope()); + } + } + +} diff --git a/src/Analyser/StmtHandler/DoWhileHandler.php b/src/Analyser/StmtHandler/DoWhileHandler.php index bd7ac56e518..483ebceb03b 100644 --- a/src/Analyser/StmtHandler/DoWhileHandler.php +++ b/src/Analyser/StmtHandler/DoWhileHandler.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\RecordingNodeCallback; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\StmtHandler; use PHPStan\DependencyInjection\AutowiredService; @@ -48,8 +49,12 @@ public function processStmt( $impurePoints = []; $originalStorage = $storage; + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $prevEntryScope = null; if ($context->isTopLevel()) { - $prevEntryScope = null; + $bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts); do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); @@ -62,7 +67,8 @@ public function processStmt( } $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -72,6 +78,13 @@ public function processStmt( foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); } + // the candidate to replace the final body walk when this pass's + // entry turns out to be the fixpoint + if ($bodyRecording instanceof RecordingNodeCallback) { + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $bodyScopeResult; + } $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); if ($bodyScope->equals($prevScope)) { break; @@ -87,7 +100,20 @@ public function processStmt( } $storage = $originalStorage; - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); + if ( + $replayBodyRecording !== null && $replayPassStorage !== null && $replayPassResult !== null + && $prevEntryScope !== null && $bodyScope->equals($prevEntryScope) + ) { + // the final body walk would repeat the recorded fixpoint pass exactly + // (same entry scope, deterministic walk) - adopt the pass's results + // and replay its emissions through the real callback instead; the + // condition walks below stay real + $originalStorage->mergeResults($replayPassStorage); + $nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage); + $bodyScopeResult = $replayPassResult; + } else { + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); + } $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index b81cf044ee2..89e7d6d866f 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -28,6 +28,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\RecordingNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\StmtHandler; @@ -135,6 +136,10 @@ public function processStmt( $iterateeScope = $nodeScopeResolver->shouldPolluteScopeWithAlwaysIterableForeach() ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; $originalStorage = $storage; + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $replayEntryScope = null; $unrolledEndScope = null; $unrolledTotalKeys = null; if ($context->isTopLevel()) { @@ -152,6 +157,7 @@ public function processStmt( $bodyScope = $this->enterForeach($nodeScopeResolver, $originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $count = 0; $prevEntryScope = null; + $bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts); do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($iterateeScope); @@ -164,11 +170,20 @@ public function processStmt( $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); $bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + // the candidate to replace the final walk when this pass's + // entry turns out to be the fixpoint + if ($bodyRecording instanceof RecordingNodeCallback) { + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $bodyScopeResult; + $replayEntryScope = $prevEntryScope; + } if ($bodyScope->equals($prevScope)) { break; } @@ -182,10 +197,24 @@ public function processStmt( } $bodyScope = $bodyScope->mergeWith($iterateeScope); + $finalEntryScope = $bodyScope; $storage = $originalStorage; $bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); - $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; - $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); + if ( + $replayBodyRecording !== null && $replayPassStorage !== null + && $replayPassResult !== null && $replayEntryScope !== null + && $unrolledTotalKeys === null && $finalEntryScope->equals($replayEntryScope) + ) { + // the final walk would repeat the recorded fixpoint pass exactly + // (same entry scope, deterministic walk) - adopt the pass's results + // and replay its emissions through the real callback instead + $originalStorage->mergeResults($replayPassStorage); + $nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage); + $finalScopeResult = $replayPassResult; + } else { + $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; + $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); + } $finalScope = $finalScopeResult->getScope(); $scopesWithIterableValueType = []; diff --git a/src/Analyser/StmtHandler/WhileHandler.php b/src/Analyser/StmtHandler/WhileHandler.php index 2b8443e6f49..1edd270d700 100644 --- a/src/Analyser/StmtHandler/WhileHandler.php +++ b/src/Analyser/StmtHandler/WhileHandler.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\RecordingNodeCallback; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\StmtHandler; use PHPStan\DependencyInjection\AutowiredService; @@ -61,9 +62,14 @@ public function processStmt( } $bodyScope = $condResult->getTruthyScope(); + $replayCondRecording = null; + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $prevEntryScope = null; if ($context->isTopLevel()) { $count = 0; - $prevEntryScope = null; + $bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts); do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); @@ -75,12 +81,22 @@ public function processStmt( } $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $condRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $condRecording, ExpressionContext::createDeep())->getTruthyScope(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + // the candidate to replace the final walk when this pass's + // entry turns out to be the fixpoint + if ($condRecording instanceof RecordingNodeCallback && $bodyRecording instanceof RecordingNodeCallback) { + $replayCondRecording = $condRecording; + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $bodyScopeResult; + } if ($bodyScope->equals($prevScope)) { break; } @@ -95,8 +111,22 @@ public function processStmt( $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; $storage = $originalStorage; - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); - $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); + if ( + $replayCondRecording !== null && $replayBodyRecording !== null + && $replayPassStorage !== null && $replayPassResult !== null + && $prevEntryScope !== null && $bodyScope->equals($prevEntryScope) + ) { + // the final walk would repeat the recorded fixpoint pass exactly + // (same entry scope, deterministic walk) - adopt the pass's results + // and replay its emissions through the real callback instead + $originalStorage->mergeResults($replayPassStorage); + $nodeScopeResolver->replayRecording($replayCondRecording, $nodeCallback, $originalStorage); + $nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage); + $finalScopeResult = $replayPassResult; + } else { + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); + } $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); $alwaysIterates = false; From 5fc876becbdb87b8babb00de9d3d38fb3f65c3b2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 23 Aug 2026 22:14:21 +0200 Subject: [PATCH 3/3] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index ceda32fadac..bcf5bf011c0 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -20,7 +20,7 @@ final class TurboExtensionEnabler * version is the short SHA of the last commit touching turbo-ext/src/, * enforced by the phar.yml turbo-version job. */ - public const EXPECTED_EXTENSION_VERSION = '4cc5e5e'; + public const EXPECTED_EXTENSION_VERSION = '83f3b7a'; private static bool $typeCombinatorCacheEnabled = false;