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/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; 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; 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 ----