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 ;
@@ -143,6 +144,17 @@ class NodeScopeResolver
143144 */
144145 private bool $ consumeStoredExpressionResults = false ;
145146
147+ /**
148+ * Engine-feeding gatherer frames (return statements, execution ends,
149+ * impure points, ...), innermost last. callNodeCallback() feeds every
150+ * frame the raw walk scope at the emission position - gatherers are
151+ * engine code and never ask about types, and their arrays are read as
152+ * soon as the enclosing body walk returns.
153+ *
154+ * @var list<callable(Node, Scope): void>
155+ */
156+ private array $ nodeGatherers = [];
157+
146158 /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */
147159 public static bool $ guardNewWorld = false ;
148160
@@ -267,9 +279,14 @@ public function processNodes(
267279
268280 $ expressionResultStorage = new ExpressionResultStorage ();
269281 $ scope ->pushExpressionResultStorage ($ expressionResultStorage );
282+ // a fresh walk an extension starts mid-analysis must not feed the
283+ // interrupted walk's gatherer frames (see processStmtNodes())
284+ $ gatherers = $ this ->nodeGatherers ;
285+ $ this ->nodeGatherers = [];
270286 try {
271287 $ this ->processNodesWithStorage ($ nodes , $ scope , $ expressionResultStorage , $ nodeCallback );
272288 } finally {
289+ $ this ->nodeGatherers = $ gatherers ;
273290 $ scope ->popExpressionResultStorage ();
274291 }
275292 }
@@ -553,6 +570,11 @@ public function processStmtNodes(
553570 $ scope = $ scope ->toWalkScope ();
554571 $ storage = new ExpressionResultStorage ();
555572 $ scope ->pushExpressionResultStorage ($ storage );
573+ // a fresh walk an extension starts mid-analysis must not feed the
574+ // interrupted walk's gatherer frames - they describe the body walk
575+ // that was interrupted, not the nested one
576+ $ gatherers = $ this ->nodeGatherers ;
577+ $ this ->nodeGatherers = [];
556578 try {
557579 return $ this ->processStmtNodesInternal (
558580 $ parentNode ,
@@ -563,6 +585,7 @@ public function processStmtNodes(
563585 $ context ,
564586 )->toPublic ();
565587 } finally {
588+ $ this ->nodeGatherers = $ gatherers ;
566589 $ scope ->popExpressionResultStorage ();
567590 }
568591 }
@@ -1347,11 +1370,40 @@ private function hasContextSensitiveConstruct(Node $node): bool
13471370 *
13481371 * @param callable(Node $node, Scope $scope): void $nodeCallback
13491372 */
1373+ /**
1374+ * Opens an engine-feeding gatherer frame for the duration of a body walk.
1375+ * The caller closes it in a finally block via popNodeGatherer().
1376+ *
1377+ * @param callable(Node, Scope): void $gatherer
1378+ */
1379+ public function pushNodeGatherer (callable $ gatherer ): void
1380+ {
1381+ $ this ->nodeGatherers [] = $ gatherer ;
1382+ }
1383+
1384+ public function popNodeGatherer (): void
1385+ {
1386+ array_pop ($ this ->nodeGatherers );
1387+ }
1388+
1389+ /**
1390+ * @param callable(Node $node, Scope $scope): void $nodeCallback
1391+ */
13501392 public function replayRecording (RecordingNodeCallback $ recording , callable $ nodeCallback , ExpressionResultStorage $ storage , MutatingScope $ scope ): void
13511393 {
13521394 $ scope ->pushExpressionResultStorage ($ storage );
13531395 try {
1354- $ recording ->replayThrough ($ nodeCallback );
1396+ foreach ($ recording ->getPairs () as [$ node , $ pairScope ]) {
1397+ if (!$ pairScope instanceof MutatingScope) {
1398+ throw new ShouldNotHappenException ();
1399+ }
1400+ // gatherer frames observe replayed emissions exactly like live
1401+ // ones - with the raw walk scope
1402+ foreach ($ this ->nodeGatherers as $ gatherer ) {
1403+ $ gatherer ($ node , $ pairScope );
1404+ }
1405+ $ nodeCallback ($ node , $ pairScope ->toNodeCallbackScope ());
1406+ }
13551407 } finally {
13561408 $ scope ->popExpressionResultStorage ();
13571409 }
@@ -1384,15 +1436,14 @@ public function callNodeCallback(
13841436 ExpressionResultStorage $ storage ,
13851437 ): void
13861438 {
1387- // Engine-feeding gatherers must observe the node at the emission
1439+ // Engine-feeding gatherer frames observe the node at the emission
13881440 // position - their arrays are read as soon as the enclosing body walk
13891441 // returns. Gatherers are engine code and never ask about types -
13901442 // handing them the raw scope skips a NodeCallbackScope construction per
13911443 // emission; the scopes they capture (return statements, impure points)
13921444 // answer later asks through the storage hub like any MutatingScope.
1393- while ($ nodeCallback instanceof GatheringNodeCallback) {
1394- ($ nodeCallback ->getGatherer ())($ node , $ scope );
1395- $ nodeCallback = $ nodeCallback ->getInner ();
1445+ foreach ($ this ->nodeGatherers as $ gatherer ) {
1446+ $ gatherer ($ node , $ scope );
13961447 }
13971448
13981449 if ($ nodeCallback instanceof NoopNodeCallback) {
@@ -1518,7 +1569,7 @@ private function processClosureNodeInternal(
15181569 $ gatheredYieldStatementsWithScope = [];
15191570 $ closureImpurePoints = [];
15201571 $ invalidateExpressions = [];
1521- $ closureStmtsCallback = new GatheringNodeCallback ( static function (Node $ node , Scope $ scope ) use (&$ executionEnds , &$ gatheredReturnStatements , &$ gatheredReturnStatementsWithScope , &$ gatheredYieldStatements , &$ gatheredYieldStatementsWithScope , &$ closureScope , &$ closureImpurePoints , &$ invalidateExpressions ): void {
1572+ $ closureStmtsGatherer = static function (Node $ node , Scope $ scope ) use (&$ executionEnds , &$ gatheredReturnStatements , &$ gatheredReturnStatementsWithScope , &$ gatheredYieldStatements , &$ gatheredYieldStatementsWithScope , &$ closureScope , &$ closureImpurePoints , &$ invalidateExpressions ): void {
15221573 if ($ scope ->getAnonymousFunctionReflection () !== $ closureScope ->getAnonymousFunctionReflection ()) {
15231574 return ;
15241575 }
@@ -1551,10 +1602,15 @@ private function processClosureNodeInternal(
15511602
15521603 $ gatheredReturnStatements [] = new ReturnStatement ($ scope , $ node );
15531604 $ gatheredReturnStatementsWithScope [] = [$ node , $ scope ];
1554- }, $ nodeCallback ) ;
1605+ };
15551606
15561607 if (count ($ byRefUses ) === 0 ) {
1557- $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ closureStmtsCallback , StatementContext::createTopLevel ());
1608+ $ this ->pushNodeGatherer ($ closureStmtsGatherer );
1609+ try {
1610+ $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ nodeCallback , StatementContext::createTopLevel ());
1611+ } finally {
1612+ $ this ->popNodeGatherer ();
1613+ }
15581614 $ publicStatementResult = $ statementResult ->toPublic ();
15591615 $ closureReturnStatementsNodeScope = $ this ->refineClosureNodeScope ($ closureScope , $ scope , $ expr , $ gatheredReturnStatementsWithScope , $ gatheredYieldStatementsWithScope , $ executionEnds , $ statementResult ->getThrowPoints (), array_merge ($ closureImpurePoints , $ statementResult ->getImpurePoints ()), $ invalidateExpressions , $ storage );
15601616 $ this ->callNodeCallback ($ nodeCallback , new ClosureReturnStatementsNode (
@@ -1632,23 +1688,28 @@ private function processClosureNodeInternal(
16321688 }
16331689
16341690 $ storage = $ originalStorage ;
1635- if (
1636- $ replayBodyRecording !== null && $ replayPassStorage !== null
1637- && $ replayPassResult !== null && $ replayEntryScope !== null
1638- && $ closureScope ->equals ($ replayEntryScope )
1639- ) {
1640- // the final walk would repeat the recorded fixpoint pass exactly
1641- // (same entry scope, deterministic walk) - adopt the pass's result
1642- // and replay its emissions through the gathering callback instead.
1643- // The pass's own entry scope takes over: the recorded pairs carry
1644- // its anonymous-function reflection, which the gathering filter
1645- // compares by identity (the state is equals-identical anyway).
1646- $ closureScope = $ replayEntryScope ;
1647- $ originalStorage ->mergeResults ($ replayPassStorage );
1648- $ this ->replayRecording ($ replayBodyRecording , $ closureStmtsCallback , $ originalStorage , $ closureScope );
1649- $ statementResult = $ replayPassResult ;
1650- } else {
1651- $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ closureStmtsCallback , StatementContext::createTopLevel ());
1691+ $ this ->pushNodeGatherer ($ closureStmtsGatherer );
1692+ try {
1693+ if (
1694+ $ replayBodyRecording !== null && $ replayPassStorage !== null
1695+ && $ replayPassResult !== null && $ replayEntryScope !== null
1696+ && $ closureScope ->equals ($ replayEntryScope )
1697+ ) {
1698+ // the final walk would repeat the recorded fixpoint pass exactly
1699+ // (same entry scope, deterministic walk) - adopt the pass's result
1700+ // and replay its emissions through the real callback instead.
1701+ // The pass's own entry scope takes over: the recorded pairs carry
1702+ // its anonymous-function reflection, which the gatherer's filter
1703+ // compares by identity (the state is equals-identical anyway).
1704+ $ closureScope = $ replayEntryScope ;
1705+ $ originalStorage ->mergeResults ($ replayPassStorage );
1706+ $ this ->replayRecording ($ replayBodyRecording , $ nodeCallback , $ originalStorage , $ closureScope );
1707+ $ statementResult = $ replayPassResult ;
1708+ } else {
1709+ $ statementResult = $ this ->processStmtNodesInternal ($ expr , $ expr ->stmts , $ closureScope , $ storage , $ nodeCallback , StatementContext::createTopLevel ());
1710+ }
1711+ } finally {
1712+ $ this ->popNodeGatherer ();
16521713 }
16531714 $ publicStatementResult = $ statementResult ->toPublic ();
16541715 $ closureReturnStatementsNodeScope = $ this ->refineClosureNodeScope ($ closureScope , $ scope , $ expr , $ gatheredReturnStatementsWithScope , $ gatheredYieldStatementsWithScope , $ executionEnds , $ statementResult ->getThrowPoints (), array_merge ($ closureImpurePoints , $ statementResult ->getImpurePoints ()), $ invalidateExpressions , $ storage );
@@ -1776,7 +1837,7 @@ public function processArrowFunctionNode(
17761837 // feeds ClosureTypeResolver::buildClosureTypeForArrowFunction().
17771838 $ arrowFunctionImpurePoints = [];
17781839 $ invalidateExpressions = [];
1779- $ arrowFunctionStmtsCallback = new GatheringNodeCallback ( static function (Node $ node , Scope $ innerScope ) use ($ arrowFunctionScope , &$ arrowFunctionImpurePoints , &$ invalidateExpressions ): void {
1840+ $ arrowFunctionStmtsGatherer = static function (Node $ node , Scope $ innerScope ) use ($ arrowFunctionScope , &$ arrowFunctionImpurePoints , &$ invalidateExpressions ): void {
17801841 if ($ innerScope ->getAnonymousFunctionReflection () !== $ arrowFunctionScope ->getAnonymousFunctionReflection ()) {
17811842 return ;
17821843 }
@@ -1798,9 +1859,14 @@ public function processArrowFunctionNode(
17981859 true ,
17991860 );
18001861 $ invalidateExpressions [] = new InvalidateExprNode ($ node ->getPropertyFetch ());
1801- }, $ nodeCallback ) ;
1862+ };
18021863
1803- $ exprResult = $ this ->processExprNode ($ stmt , $ expr ->expr , $ arrowFunctionScope , $ storage , $ arrowFunctionStmtsCallback , ExpressionContext::createTopLevel ());
1864+ $ this ->pushNodeGatherer ($ arrowFunctionStmtsGatherer );
1865+ try {
1866+ $ exprResult = $ this ->processExprNode ($ stmt , $ expr ->expr , $ arrowFunctionScope , $ storage , $ nodeCallback , ExpressionContext::createTopLevel ());
1867+ } finally {
1868+ $ this ->popNodeGatherer ();
1869+ }
18041870
18051871 $ closureTypeThrowPoints = array_map (static fn (InternalThrowPoint $ throwPoint ) => $ throwPoint ->toPublic (), $ exprResult ->getThrowPoints ());
18061872 $ closureTypeImpurePoints = array_merge ($ arrowFunctionImpurePoints , $ exprResult ->getImpurePoints ());
0 commit comments