Skip to content

Commit 31861fc

Browse files
ondrejmirtesclaude
andcommitted
Adopt the upstream convergence replay implementation
The branch's own fixpoint-replay commits were dropped during the rebase over the merged extraction chunks (#6249); this restores the replay in upstream's final form - raw-recorded pairs wrapped at replay time, the RecordingNodeCallback short-circuit in callNodeCallback - woven into the branch's handler shapes (ambient storage push around pass walks, the deferred While_ statement callback, the on-demand falsey cond re-pricing). replayRecording() binds the storage through the scope's push/pop like every other branch-side ambient binding. The consume mechanism (#6251, closed unmerged) is gone entirely: convergence behavior on this branch is now byte-for-byte upstream's algorithm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
1 parent 21c530f commit 31861fc

4 files changed

Lines changed: 232 additions & 17 deletions

File tree

‎src/Analyser/NodeScopeResolver.php‎

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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(

‎src/Analyser/StmtHandler/DoWhileHandler.php‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPStan\Analyser\MutatingScope;
1313
use PHPStan\Analyser\NodeScopeResolver;
1414
use PHPStan\Analyser\NoopNodeCallback;
15+
use PHPStan\Analyser\RecordingNodeCallback;
1516
use PHPStan\Analyser\StatementContext;
1617
use PHPStan\Analyser\StmtHandler;
1718
use PHPStan\DependencyInjection\AutowiredService;
@@ -48,8 +49,12 @@ public function processStmt(
4849
$impurePoints = [];
4950
$originalStorage = $storage;
5051

52+
$replayBodyRecording = null;
53+
$replayPassStorage = null;
54+
$replayPassResult = null;
55+
$prevEntryScope = null;
5156
if ($context->isTopLevel()) {
52-
$prevEntryScope = null;
57+
$bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts);
5358
do {
5459
$prevScope = $bodyScope;
5560
$bodyScope = $bodyScope->mergeWith($scope);
@@ -62,9 +67,10 @@ public function processStmt(
6267
}
6368
$prevEntryScope = $bodyScope;
6469
$storage = $originalStorage->duplicate();
70+
$bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback();
6571
$scope->pushExpressionResultStorage($storage);
6672
try {
67-
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
73+
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints();
6874
$alwaysTerminating = $bodyScopeResult->isAlwaysTerminating();
6975
$bodyScope = $bodyScopeResult->getScope();
7076
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
@@ -74,6 +80,13 @@ public function processStmt(
7480
foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
7581
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
7682
}
83+
// the candidate to replace the final body walk when this pass's
84+
// entry turns out to be the fixpoint
85+
if ($bodyRecording instanceof RecordingNodeCallback) {
86+
$replayBodyRecording = $bodyRecording;
87+
$replayPassStorage = $storage;
88+
$replayPassResult = $bodyScopeResult;
89+
}
7790
$bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope();
7891
} finally {
7992
$scope->popExpressionResultStorage();
@@ -92,7 +105,20 @@ public function processStmt(
92105
}
93106

94107
$storage = $originalStorage;
95-
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
108+
if (
109+
$replayBodyRecording !== null && $replayPassStorage !== null && $replayPassResult !== null
110+
&& $prevEntryScope !== null && $bodyScope->equals($prevEntryScope)
111+
) {
112+
// the final body walk would repeat the recorded fixpoint pass exactly
113+
// (same entry scope, deterministic walk) - adopt the pass's results
114+
// and replay its emissions through the real callback instead; the
115+
// condition walks below stay real
116+
$originalStorage->mergeResults($replayPassStorage);
117+
$nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage, $scope);
118+
$bodyScopeResult = $replayPassResult;
119+
} else {
120+
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
121+
}
96122
$bodyScope = $bodyScopeResult->getScope();
97123
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
98124
$bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope());

‎src/Analyser/StmtHandler/ForeachHandler.php‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use PHPStan\Analyser\MutatingScope;
3030
use PHPStan\Analyser\NodeScopeResolver;
3131
use PHPStan\Analyser\NoopNodeCallback;
32+
use PHPStan\Analyser\RecordingNodeCallback;
3233
use PHPStan\Analyser\Scope;
3334
use PHPStan\Analyser\StatementContext;
3435
use PHPStan\Analyser\StmtHandler;
@@ -173,6 +174,10 @@ static function () use ($condResult, $emptyArrayType): Type {
173174
}
174175

175176
$originalStorage = $storage;
177+
$replayBodyRecording = null;
178+
$replayPassStorage = null;
179+
$replayPassResult = null;
180+
$replayEntryScope = null;
176181
$unrolledEndScope = null;
177182
$unrolledTotalKeys = null;
178183
if ($context->isTopLevel()) {
@@ -207,6 +212,7 @@ static function () use ($condResult, $emptyArrayType): Type {
207212
}
208213
$count = 0;
209214
$prevEntryScope = null;
215+
$bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts);
210216
do {
211217
$prevScope = $bodyScope;
212218
$bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope);
@@ -218,17 +224,26 @@ static function () use ($condResult, $emptyArrayType): Type {
218224
}
219225
$prevEntryScope = $bodyScope;
220226
$storage = $originalStorage->duplicate();
227+
$bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback();
221228
$scope->pushExpressionResultStorage($storage);
222229
try {
223230
$bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
224-
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
231+
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints();
225232
$bodyScope = $bodyScopeResult->getScope();
226233
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
227234
$bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope());
228235
}
229236
} finally {
230237
$scope->popExpressionResultStorage();
231238
}
239+
// the candidate to replace the final walk when this pass's
240+
// entry turns out to be the fixpoint
241+
if ($bodyRecording instanceof RecordingNodeCallback) {
242+
$replayBodyRecording = $bodyRecording;
243+
$replayPassStorage = $storage;
244+
$replayPassResult = $bodyScopeResult;
245+
$replayEntryScope = $prevEntryScope;
246+
}
232247
if ($bodyScope->equals($prevScope)) {
233248
break;
234249
}
@@ -242,10 +257,24 @@ static function () use ($condResult, $emptyArrayType): Type {
242257
}
243258

244259
$bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope);
260+
$finalEntryScope = $bodyScope;
245261
$storage = $originalStorage;
246262
$bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
247-
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
248-
$finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
263+
if (
264+
$replayBodyRecording !== null && $replayPassStorage !== null
265+
&& $replayPassResult !== null && $replayEntryScope !== null
266+
&& $unrolledTotalKeys === null && $finalEntryScope->equals($replayEntryScope)
267+
) {
268+
// the final walk would repeat the recorded fixpoint pass exactly
269+
// (same entry scope, deterministic walk) - adopt the pass's results
270+
// and replay its emissions through the real callback instead
271+
$originalStorage->mergeResults($replayPassStorage);
272+
$nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage, $scope);
273+
$finalScopeResult = $replayPassResult;
274+
} else {
275+
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
276+
$finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
277+
}
249278
$finalScope = $finalScopeResult->getScope();
250279
$scopesWithIterableValueType = [];
251280

0 commit comments

Comments
 (0)