105105use function array_last ;
106106use function array_map ;
107107use function array_merge ;
108+ use function array_pop ;
108109use function array_slice ;
109110use function array_values ;
110111use function count ;
@@ -144,12 +145,20 @@ class NodeScopeResolver
144145 private bool $ consumeStoredExpressionResults = false ;
145146
146147 /**
147- * Convergence-pass mode (see enterConvergencePass()): a fixpoint re-walk
148- * may consume the previous pass's stored result of an effect-free
149- * expression whose read variables did not change - only the loop-carried
150- * parts of the body re-walk.
148+ * The stack of active convergence passes (see enterConvergencePass()),
149+ * innermost last; a fixpoint re-walk may consume the previous pass's
150+ * stored result of an effect-free expression whose read variables did not
151+ * change - only the loop-carried parts of the body re-walk. Each entry
152+ * records whether that pass consumed anything: a consumed subtree's
153+ * emissions are missing from the pass's recording, so only a gap-free
154+ * pass's recording can replace the final walk. A consumption is
155+ * attributed to the innermost active pass only - inner convergence
156+ * (a by-ref closure inside a loop body) does not gap the outer recording,
157+ * which received the inner FINAL walk's emissions in full.
158+ *
159+ * @var array<int, bool>
151160 */
152- private bool $ consumeEffectFreeStoredResults = false ;
161+ private array $ convergencePassGaps = [] ;
153162
154163 /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */
155164 public static bool $ guardNewWorld = false ;
@@ -290,24 +299,24 @@ public function processNodes(
290299 * flags - on-demand stored-result reuse and convergence-pass consumption
291300 * describe the walk that was interrupted, not the nested one.
292301 *
293- * @return array{bool, bool, bool}
302+ * @return array{bool, bool, array<int, bool> }
294303 */
295304 private function suspendWalkModes (): array
296305 {
297- $ modes = [$ this ->returnStoredExpressionResults , $ this ->consumeStoredExpressionResults , $ this ->consumeEffectFreeStoredResults ];
306+ $ modes = [$ this ->returnStoredExpressionResults , $ this ->consumeStoredExpressionResults , $ this ->convergencePassGaps ];
298307 $ this ->returnStoredExpressionResults = false ;
299308 $ this ->consumeStoredExpressionResults = false ;
300- $ this ->consumeEffectFreeStoredResults = false ;
309+ $ this ->convergencePassGaps = [] ;
301310
302311 return $ modes ;
303312 }
304313
305314 /**
306- * @param array{bool, bool, bool} $modes
315+ * @param array{bool, bool, array<int, bool> } $modes
307316 */
308317 private function restoreWalkModes (array $ modes ): void
309318 {
310- [$ this ->returnStoredExpressionResults , $ this ->consumeStoredExpressionResults , $ this ->consumeEffectFreeStoredResults ] = $ modes ;
319+ [$ this ->returnStoredExpressionResults , $ this ->consumeStoredExpressionResults , $ this ->convergencePassGaps ] = $ modes ;
311320 }
312321
313322 /**
@@ -479,7 +488,7 @@ private function resolveBackwardGotoScope(
479488 }
480489 $ prevEntryScope = $ bodyScope ;
481490 $ tempStorage = ($ previousPassStorage ?? $ storage )->duplicate ();
482- $ previousConvergencePass = $ this ->enterConvergencePass ();
491+ $ this ->enterConvergencePass ();
483492 try {
484493 $ bodyScopeResult = $ this ->processStmtNodesInternal (
485494 $ parentNode ,
@@ -490,7 +499,7 @@ private function resolveBackwardGotoScope(
490499 $ context ,
491500 );
492501 } finally {
493- $ this ->exitConvergencePass ($ previousConvergencePass );
502+ $ this ->exitConvergencePass ();
494503 }
495504 $ previousPassStorage = $ tempStorage ;
496505
@@ -1006,20 +1015,23 @@ public function processExprNodeConsumingStored(Node\Stmt $stmt, Expr $expr, Muta
10061015 * whose stored result is effect-free (ExpressionResult::isEffectFree())
10071016 * and whose read variables did not change since the pass that stored it
10081017 * are consumed instead of re-walked. The caller chains the previous pass's
1009- * storage as the new pass storage's fallback and restores the returned
1010- * previous mode in a finally block via exitConvergencePass().
1018+ * storage as the new pass storage's fallback and pops the pass in a
1019+ * finally block via exitConvergencePass().
10111020 */
1012- public function enterConvergencePass (): bool
1021+ public function enterConvergencePass (): void
10131022 {
1014- $ previous = $ this ->consumeEffectFreeStoredResults ;
1015- $ this ->consumeEffectFreeStoredResults = true ;
1016-
1017- return $ previous ;
1023+ $ this ->convergencePassGaps [] = false ;
10181024 }
10191025
1020- public function exitConvergencePass (bool $ previous ): void
1026+ /** Returns whether the exited pass consumed anything (a recording gap). */
1027+ public function exitConvergencePass (): bool
10211028 {
1022- $ this ->consumeEffectFreeStoredResults = $ previous ;
1029+ $ gapped = array_pop ($ this ->convergencePassGaps );
1030+ if ($ gapped === null ) {
1031+ throw new ShouldNotHappenException ();
1032+ }
1033+
1034+ return $ gapped ;
10231035 }
10241036
10251037 public function processExprOnDemand (Expr $ expr , MutatingScope $ scope , ExpressionResultStorage $ storage ): ExpressionResult
@@ -1233,7 +1245,7 @@ public function processExprNode(
12331245
12341246 return $ reanchored ;
12351247 }
1236- } elseif ($ this ->consumeEffectFreeStoredResults ) {
1248+ } elseif ($ this ->convergencePassGaps !== [] ) {
12371249 // a convergence pass: the previous pass's result answers exactly
12381250 // when the walk had no scope effects and nothing this expression
12391251 // reads changed between the passes - the loop-carried parts of the
@@ -1244,6 +1256,7 @@ public function processExprNode(
12441256 && $ storedResult ->isEffectFree ()
12451257 && $ storedResult ->askScopeVariableStateMatches ($ scope , $ scope ->nativeTypesPromoted )
12461258 ) {
1259+ $ this ->convergencePassGaps [count ($ this ->convergencePassGaps ) - 1 ] = true ;
12471260 if ($ storedResult ->getBeforeScope () === $ scope ) {
12481261 return $ storedResult ;
12491262 }
@@ -1255,6 +1268,71 @@ public function processExprNode(
12551268 return $ this ->processExprNodeInternal ($ stmt , $ expr , $ scope , $ storage , $ nodeCallback , $ context );
12561269 }
12571270
1271+ private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody ' ;
1272+
1273+ /**
1274+ * Whether a recorded convergence pass over the loop body can replace the
1275+ * final walk. A pass runs at deep statement context, the final walk at top
1276+ * level - constructs that analyse differently between the two (nested
1277+ * loop/label fixpoints run only at top level, statement-level classes are
1278+ * skipped at deep context) disqualify the body. Closure bodies process
1279+ * context-independently and are not traversed.
1280+ *
1281+ * @param Node\Stmt[] $bodyStmts
1282+ */
1283+ public function isReplayableConvergenceBody (Node $ loopNode , array $ bodyStmts ): bool
1284+ {
1285+ $ cached = $ loopNode ->getAttribute (self ::REPLAYABLE_BODY_ATTRIBUTE );
1286+ if ($ cached !== null ) {
1287+ return $ cached ;
1288+ }
1289+
1290+ $ replayable = true ;
1291+ foreach ($ bodyStmts as $ bodyStmt ) {
1292+ if ($ this ->hasContextSensitiveConstruct ($ bodyStmt )) {
1293+ $ replayable = false ;
1294+ break ;
1295+ }
1296+ }
1297+ $ loopNode ->setAttribute (self ::REPLAYABLE_BODY_ATTRIBUTE , $ replayable );
1298+
1299+ return $ replayable ;
1300+ }
1301+
1302+ private function hasContextSensitiveConstruct (Node $ node ): bool
1303+ {
1304+ if ($ node instanceof Expr \Closure) {
1305+ return false ;
1306+ }
1307+ if (
1308+ $ node instanceof Node \Stmt \While_
1309+ || $ node instanceof Node \Stmt \Do_
1310+ || $ node instanceof Node \Stmt \For_
1311+ || $ node instanceof Foreach_
1312+ || $ node instanceof Node \Stmt \Label
1313+ || $ node instanceof Node \Stmt \ClassLike
1314+ ) {
1315+ return true ;
1316+ }
1317+
1318+ foreach ($ node ->getSubNodeNames () as $ subNodeName ) {
1319+ $ subNode = $ node ->$ subNodeName ;
1320+ if ($ subNode instanceof Node) {
1321+ if ($ this ->hasContextSensitiveConstruct ($ subNode )) {
1322+ return true ;
1323+ }
1324+ } elseif (is_array ($ subNode )) {
1325+ foreach ($ subNode as $ item ) {
1326+ if ($ item instanceof Node && $ this ->hasContextSensitiveConstruct ($ item )) {
1327+ return true ;
1328+ }
1329+ }
1330+ }
1331+ }
1332+
1333+ return false ;
1334+ }
1335+
12581336 /**
12591337 * @param callable(Node $node, Scope $scope): void $nodeCallback
12601338 */
@@ -1577,6 +1655,11 @@ private function processClosureNodeInternal(
15771655
15781656 $ count = 0 ;
15791657 $ closureResultScope = null ;
1658+ $ replayBodyRecording = null ;
1659+ $ replayPassStorage = null ;
1660+ $ replayPassResult = null ;
1661+ $ replayEntryScope = null ;
1662+ $ bodyIsReplayable = $ this ->isReplayableConvergenceBody ($ expr , $ expr ->stmts );
15801663 // each pass chains the previous pass's storage as its fallback, so
15811664 // the convergence-pass mode consumes unchanged effect-free subtrees
15821665 // instead of re-walking the whole body every round
@@ -1585,17 +1668,33 @@ private function processClosureNodeInternal(
15851668 $ prevScope = $ closureScope ;
15861669
15871670 $ storage = ($ previousPassStorage ?? $ originalStorage )->duplicate ();
1588- $ previousConvergencePass = $ this ->enterConvergencePass ();
1671+ $ this ->enterConvergencePass ();
1672+ $ passGapped = false ;
1673+ $ bodyRecording = $ bodyIsReplayable ? new RecordingNodeCallback () : new NoopNodeCallback ();
15891674 try {
15901675 // deep context, like the loop handlers' own convergence passes: inner
15911676 // loops walk single-pass here and only the final walk below (top-level)
15921677 // runs their full convergence - otherwise every closure-convergence
15931678 // pass would re-converge every inner loop from scratch
1594- $ intermediaryClosureScopeResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , new NoopNodeCallback () , StatementContext::createDeep ());
1679+ $ intermediaryClosureScopeResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ bodyRecording , StatementContext::createDeep ());
15951680 } finally {
1596- $ this ->exitConvergencePass ($ previousConvergencePass );
1681+ $ passGapped = $ this ->exitConvergencePass ();
15971682 }
15981683 $ previousPassStorage = $ storage ;
1684+ // a pass that consumed nothing has a complete recording - the
1685+ // candidate to replace the final walk when its entry turns out
1686+ // to be the fixpoint
1687+ if ($ bodyRecording instanceof RecordingNodeCallback && !$ passGapped ) {
1688+ $ replayBodyRecording = $ bodyRecording ;
1689+ $ replayPassStorage = $ storage ;
1690+ $ replayPassResult = $ intermediaryClosureScopeResult ;
1691+ $ replayEntryScope = $ prevScope ;
1692+ } else {
1693+ $ replayBodyRecording = null ;
1694+ $ replayPassStorage = null ;
1695+ $ replayPassResult = null ;
1696+ $ replayEntryScope = null ;
1697+ }
15991698 $ intermediaryClosureScope = $ intermediaryClosureScopeResult ->getScope ();
16001699 foreach ($ intermediaryClosureScopeResult ->getExitPoints () as $ exitPoint ) {
16011700 $ intermediaryClosureScope = $ intermediaryClosureScope ->mergeWith ($ exitPoint ->getScope ());
@@ -1623,7 +1722,20 @@ private function processClosureNodeInternal(
16231722 }
16241723
16251724 $ storage = $ originalStorage ;
1626- $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ closureStmtsCallback , StatementContext::createTopLevel ());
1725+ if ($ replayBodyRecording !== null && $ closureScope ->equals ($ replayEntryScope )) {
1726+ // the final walk would repeat the recorded fixpoint pass exactly
1727+ // (same entry scope, deterministic walk) - adopt the pass's result
1728+ // and replay its emissions through the gathering callback instead.
1729+ // The pass's own entry scope takes over: the recorded pairs carry
1730+ // its anonymous-function reflection, which the gathering filter
1731+ // compares by identity (the state is equals-identical anyway).
1732+ $ closureScope = $ replayEntryScope ;
1733+ $ originalStorage ->mergeResults ($ replayPassStorage );
1734+ $ replayBodyRecording ->replayThrough ($ closureStmtsCallback );
1735+ $ statementResult = $ replayPassResult ;
1736+ } else {
1737+ $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ closureStmtsCallback , StatementContext::createTopLevel ());
1738+ }
16271739 $ publicStatementResult = $ statementResult ->toPublic ();
16281740 $ closureReturnStatementsNodeScope = $ this ->refineClosureNodeScope ($ closureScope , $ scope , $ expr , $ gatheredReturnStatementsWithScope , $ gatheredYieldStatementsWithScope , $ executionEnds , $ statementResult ->getThrowPoints (), array_merge ($ closureImpurePoints , $ statementResult ->getImpurePoints ()), $ invalidateExpressions , $ storage );
16291741 $ this ->callNodeCallback ($ nodeCallback , new ClosureReturnStatementsNode (
0 commit comments