@@ -1272,6 +1272,90 @@ public function getAssignedVariables(Expr $expr): array
12721272 return [];
12731273 }
12741274
1275+ private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody ' ;
1276+
1277+ /**
1278+ * Whether a recorded convergence pass over the loop body can replace the
1279+ * final walk. A pass runs at deep statement context, the final walk at top
1280+ * level - constructs that analyse differently between the two (nested
1281+ * loop/label fixpoints run only at top level, statement-level classes are
1282+ * skipped at deep context) disqualify the body. Closure bodies process
1283+ * context-independently and are not traversed.
1284+ *
1285+ * @param Node\Stmt[] $bodyStmts
1286+ */
1287+ public function isReplayableConvergenceBody (Node $ loopNode , array $ bodyStmts ): bool
1288+ {
1289+ $ cached = $ loopNode ->getAttribute (self ::REPLAYABLE_BODY_ATTRIBUTE );
1290+ if ($ cached !== null ) {
1291+ return $ cached ;
1292+ }
1293+
1294+ $ replayable = true ;
1295+ foreach ($ bodyStmts as $ bodyStmt ) {
1296+ if ($ this ->hasContextSensitiveConstruct ($ bodyStmt )) {
1297+ $ replayable = false ;
1298+ break ;
1299+ }
1300+ }
1301+ $ loopNode ->setAttribute (self ::REPLAYABLE_BODY_ATTRIBUTE , $ replayable );
1302+
1303+ return $ replayable ;
1304+ }
1305+
1306+ private function hasContextSensitiveConstruct (Node $ node ): bool
1307+ {
1308+ if ($ node instanceof Expr \Closure) {
1309+ return false ;
1310+ }
1311+ if (
1312+ $ node instanceof Node \Stmt \While_
1313+ || $ node instanceof Node \Stmt \Do_
1314+ || $ node instanceof Node \Stmt \For_
1315+ || $ node instanceof Foreach_
1316+ || $ node instanceof Node \Stmt \Label
1317+ || $ node instanceof Node \Stmt \ClassLike
1318+ ) {
1319+ return true ;
1320+ }
1321+
1322+ foreach ($ node ->getSubNodeNames () as $ subNodeName ) {
1323+ $ subNode = $ node ->$ subNodeName ;
1324+ if ($ subNode instanceof Node) {
1325+ if ($ this ->hasContextSensitiveConstruct ($ subNode )) {
1326+ return true ;
1327+ }
1328+ } elseif (is_array ($ subNode )) {
1329+ foreach ($ subNode as $ item ) {
1330+ if ($ item instanceof Node && $ this ->hasContextSensitiveConstruct ($ item )) {
1331+ return true ;
1332+ }
1333+ }
1334+ }
1335+ }
1336+
1337+ return false ;
1338+ }
1339+
1340+ /**
1341+ * Replays a recorded convergence pass's emissions through the real node
1342+ * callback in place of the final loop walk. The pass's storage was merged
1343+ * into $storage by the caller; binding it for the whole replay lets the
1344+ * recorded scopes answer rule asks from the stored before-scopes, the
1345+ * same way the repeated walk's per-emission binding would.
1346+ *
1347+ * @param callable(Node $node, Scope $scope): void $nodeCallback
1348+ */
1349+ public function replayRecording (RecordingNodeCallback $ recording , callable $ nodeCallback , ExpressionResultStorage $ storage , MutatingScope $ scope ): void
1350+ {
1351+ $ scope ->pushExpressionResultStorage ($ storage );
1352+ try {
1353+ $ recording ->replayThrough ($ nodeCallback );
1354+ } finally {
1355+ $ scope ->popExpressionResultStorage ();
1356+ }
1357+ }
1358+
12751359 /**
12761360 * @param callable(Node $node, Scope $scope): void $nodeCallback
12771361 */
@@ -1314,6 +1398,13 @@ public function callNodeCallback(
13141398 return ;
13151399 }
13161400
1401+ if ($ nodeCallback instanceof RecordingNodeCallback) {
1402+ // recording never asks about types - the pairs are wrapped and
1403+ // bound to the storage at replay time instead
1404+ $ nodeCallback ($ node , $ scope );
1405+ return ;
1406+ }
1407+
13171408 // post-order emission means the node's own result and every subnode
13181409 // result are already stored when the callback fires - NodeCallbackScope
13191410 // answers every ask synchronously from the storage
@@ -1490,15 +1581,29 @@ private function processClosureNodeInternal(
14901581
14911582 $ count = 0 ;
14921583 $ closureResultScope = null ;
1584+ $ replayBodyRecording = null ;
1585+ $ replayPassStorage = null ;
1586+ $ replayPassResult = null ;
1587+ $ replayEntryScope = null ;
1588+ $ bodyIsReplayable = $ this ->isReplayableConvergenceBody ($ expr , $ expr ->stmts );
14931589 do {
14941590 $ prevScope = $ closureScope ;
14951591
14961592 $ storage = $ originalStorage ->duplicate ();
1593+ $ bodyRecording = $ bodyIsReplayable ? new RecordingNodeCallback () : new NoopNodeCallback ();
14971594 // deep context, like the loop handlers' own convergence passes: inner
14981595 // loops walk single-pass here and only the final walk below (top-level)
14991596 // runs their full convergence - otherwise every closure-convergence
15001597 // pass would re-converge every inner loop from scratch
1501- $ intermediaryClosureScopeResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , new NoopNodeCallback (), StatementContext::createDeep ());
1598+ $ intermediaryClosureScopeResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ bodyRecording , StatementContext::createDeep ());
1599+ // the candidate to replace the final walk when this pass's entry
1600+ // turns out to be the fixpoint
1601+ if ($ bodyRecording instanceof RecordingNodeCallback) {
1602+ $ replayBodyRecording = $ bodyRecording ;
1603+ $ replayPassStorage = $ storage ;
1604+ $ replayPassResult = $ intermediaryClosureScopeResult ;
1605+ $ replayEntryScope = $ prevScope ;
1606+ }
15021607 $ intermediaryClosureScope = $ intermediaryClosureScopeResult ->getScope ();
15031608 foreach ($ intermediaryClosureScopeResult ->getExitPoints () as $ exitPoint ) {
15041609 $ intermediaryClosureScope = $ intermediaryClosureScope ->mergeWith ($ exitPoint ->getScope ());
@@ -1526,7 +1631,24 @@ private function processClosureNodeInternal(
15261631 }
15271632
15281633 $ storage = $ originalStorage ;
1529- $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ closureStmtsCallback , StatementContext::createTopLevel ());
1634+ if (
1635+ $ replayBodyRecording !== null && $ replayPassStorage !== null
1636+ && $ replayPassResult !== null && $ replayEntryScope !== null
1637+ && $ closureScope ->equals ($ replayEntryScope )
1638+ ) {
1639+ // the final walk would repeat the recorded fixpoint pass exactly
1640+ // (same entry scope, deterministic walk) - adopt the pass's result
1641+ // and replay its emissions through the gathering callback instead.
1642+ // The pass's own entry scope takes over: the recorded pairs carry
1643+ // its anonymous-function reflection, which the gathering filter
1644+ // compares by identity (the state is equals-identical anyway).
1645+ $ closureScope = $ replayEntryScope ;
1646+ $ originalStorage ->mergeResults ($ replayPassStorage );
1647+ $ this ->replayRecording ($ replayBodyRecording , $ closureStmtsCallback , $ originalStorage , $ closureScope );
1648+ $ statementResult = $ replayPassResult ;
1649+ } else {
1650+ $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ closureStmtsCallback , StatementContext::createTopLevel ());
1651+ }
15301652 $ publicStatementResult = $ statementResult ->toPublic ();
15311653 $ closureReturnStatementsNodeScope = $ this ->refineClosureNodeScope ($ closureScope , $ scope , $ expr , $ gatheredReturnStatementsWithScope , $ gatheredYieldStatementsWithScope , $ executionEnds , $ statementResult ->getThrowPoints (), array_merge ($ closureImpurePoints , $ statementResult ->getImpurePoints ()), $ invalidateExpressions , $ storage );
15321654 $ this ->callNodeCallback ($ nodeCallback , new ClosureReturnStatementsNode (
0 commit comments