From f25458b02501c2915ec85d34a918292b759c31fd Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 20 May 2026 13:36:10 +0000 Subject: [PATCH 1/2] Handle `goto` and `Label` in top-level file statements processed by `processNodes` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GotoLabelVisitor::afterTraverse now calls processStatementList on root-level nodes so that top-level labels get HAS_BACKWARD_GOTO_ATTRIBUTE and statements get NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE - NodeScopeResolver::processNodes now tracks exit points, merges forward goto scopes at labels, and runs the backward goto pre-analysis loop — mirroring the goto handling already present in processStmtNodesInternalWithoutFlushingPendingFibers - Namespace-level goto was already handled correctly because Namespace_ bodies go through processStmtNodesInternal --- src/Analyser/NodeScopeResolver.php | 139 +++++++++++++++++- src/Parser/GotoLabelVisitor.php | 9 ++ .../Analyser/nsrt/bug-14660-top-level.php | 20 +++ tests/PHPStan/Analyser/nsrt/bug-14660.php | 24 +++ 4 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14660-top-level.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14660.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e5b891e4ab..0dff6b4fb6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -278,22 +278,151 @@ public function processNodes( { $expressionResultStorage = new ExpressionResultStorage(); $alreadyTerminated = false; + $exitPoints = []; + + $stmts = []; + $stmtToNodeIndex = []; foreach ($nodes as $i => $node) { - if ( - !$node instanceof Node\Stmt - || ($alreadyTerminated && !($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassLike)) - ) { + if (!($node instanceof Node\Stmt)) { continue; } + $stmtToNodeIndex[count($stmts)] = $i; + $stmts[] = $node; + } + + $dummyParent = new Node\Stmt\Nop(); + foreach ($stmts as $si => $node) { + if ($alreadyTerminated && !($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\Label)) { + continue; + } + + $nestedLabelNames = $node->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE); + if ($nestedLabelNames !== null) { + $originalStorage = $expressionResultStorage; + $bodyScope = $scope; + $count = 0; + do { + $prevScope = $bodyScope; + $tempStorage = $originalStorage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal( + $dummyParent, + [$node], + $bodyScope, + $tempStorage, + new NoopNodeCallback(), + StatementContext::createDeep(), + ); + + $gotoScope = null; + foreach ($bodyScopeResult->getExitPoints() as $ep) { + $epStmt = $ep->getStatement(); + if (!($epStmt instanceof Goto_) || !isset($nestedLabelNames[$epStmt->name->toString()])) { + continue; + } + + $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); + } + + if ($gotoScope !== null) { + $bodyScope = $scope->mergeWith($gotoScope); + } + + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $scope = $bodyScope; + $expressionResultStorage = $originalStorage; + } + $statementResult = $this->processStmtNode($node, $scope, $expressionResultStorage, $nodeCallback, StatementContext::createTopLevel()); $scope = $statementResult->getScope(); + + if ($node instanceof Node\Stmt\Label) { + $labelName = $node->name->toString(); + + $newExitPoints = []; + foreach ($exitPoints as $exitPoint) { + $exitStmt = $exitPoint->getStatement(); + if ($exitStmt instanceof Goto_ && $exitStmt->name->toString() === $labelName) { + if ($alreadyTerminated) { + $scope = $exitPoint->getScope(); + $alreadyTerminated = false; + } else { + $scope = $scope->mergeWith($exitPoint->getScope()); + } + } else { + $newExitPoints[] = $exitPoint; + } + } + $exitPoints = $newExitPoints; + + if ($alreadyTerminated) { + continue; + } + + if ($node->getAttribute(GotoLabelVisitor::HAS_BACKWARD_GOTO_ATTRIBUTE) === true) { + $originalStorage = $expressionResultStorage; + $bodyStmts = array_slice($stmts, $si + 1); + $bodyScope = $scope; + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $tempStorage = $originalStorage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal( + $dummyParent, + $bodyStmts, + $bodyScope, + $tempStorage, + new NoopNodeCallback(), + StatementContext::createDeep(), + ); + + $gotoScope = null; + foreach ($bodyScopeResult->getExitPoints() as $ep) { + $epStmt = $ep->getStatement(); + if (!($epStmt instanceof Goto_) || $epStmt->name->toString() !== $labelName) { + continue; + } + + $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); + } + + if ($gotoScope !== null) { + $bodyScope = $scope->mergeWith($gotoScope); + } + + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $scope = $bodyScope; + $expressionResultStorage = $originalStorage; + } + } + + $exitPoints = array_merge($exitPoints, $statementResult->getExitPoints()); + if ($alreadyTerminated || !$statementResult->isAlwaysTerminating()) { continue; } $alreadyTerminated = true; - $nextStmts = $this->getNextUnreachableStatements(array_slice($nodes, $i + 1), true); + $nextStmts = $this->getNextUnreachableStatements(array_slice($nodes, $stmtToNodeIndex[$si] + 1), true); $this->processUnreachableStatement($nextStmts, $scope, $expressionResultStorage, $nodeCallback); } diff --git a/src/Parser/GotoLabelVisitor.php b/src/Parser/GotoLabelVisitor.php index efb620cfbd..1c8691e70e 100644 --- a/src/Parser/GotoLabelVisitor.php +++ b/src/Parser/GotoLabelVisitor.php @@ -46,6 +46,15 @@ public function beforeTraverse(array $nodes): ?array #[Override] public function afterTraverse(array $nodes): ?array { + $stmts = []; + foreach ($nodes as $node) { + if (!($node instanceof Node\Stmt)) { + continue; + } + + $stmts[] = $node; + } + $this->processStatementList($stmts); $this->popScope(); $this->subtreeData = []; return null; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14660-top-level.php b/tests/PHPStan/Analyser/nsrt/bug-14660-top-level.php new file mode 100644 index 0000000000..61c0c1c54f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14660-top-level.php @@ -0,0 +1,20 @@ + Date: Wed, 20 May 2026 13:47:36 +0000 Subject: [PATCH 2/2] Extract shared goto scope resolution helpers to deduplicate processNodes and processStmtNodesInternalWithoutFlushingPendingFibers Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 333 ++++++++++++----------------- 1 file changed, 135 insertions(+), 198 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0dff6b4fb6..a52b1c94f2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -299,47 +299,15 @@ public function processNodes( $nestedLabelNames = $node->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE); if ($nestedLabelNames !== null) { - $originalStorage = $expressionResultStorage; - $bodyScope = $scope; - $count = 0; - do { - $prevScope = $bodyScope; - $tempStorage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal( - $dummyParent, - [$node], - $bodyScope, - $tempStorage, - new NoopNodeCallback(), - StatementContext::createDeep(), - ); - - $gotoScope = null; - foreach ($bodyScopeResult->getExitPoints() as $ep) { - $epStmt = $ep->getStatement(); - if (!($epStmt instanceof Goto_) || !isset($nestedLabelNames[$epStmt->name->toString()])) { - continue; - } - - $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); - } - - if ($gotoScope !== null) { - $bodyScope = $scope->mergeWith($gotoScope); - } - - if ($bodyScope->equals($prevScope)) { - break; - } - - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - - $scope = $bodyScope; - $expressionResultStorage = $originalStorage; + $scope = $this->resolveBackwardGotoScope( + $dummyParent, + [$node], + $scope, + $expressionResultStorage, + StatementContext::createDeep(), + static fn (string $name): bool => isset($nestedLabelNames[$name]), + false, + ); } $statementResult = $this->processStmtNode($node, $scope, $expressionResultStorage, $nodeCallback, StatementContext::createTopLevel()); @@ -348,70 +316,27 @@ public function processNodes( if ($node instanceof Node\Stmt\Label) { $labelName = $node->name->toString(); - $newExitPoints = []; - foreach ($exitPoints as $exitPoint) { - $exitStmt = $exitPoint->getStatement(); - if ($exitStmt instanceof Goto_ && $exitStmt->name->toString() === $labelName) { - if ($alreadyTerminated) { - $scope = $exitPoint->getScope(); - $alreadyTerminated = false; - } else { - $scope = $scope->mergeWith($exitPoint->getScope()); - } - } else { - $newExitPoints[] = $exitPoint; - } - } - $exitPoints = $newExitPoints; + [$scope, $alreadyTerminated, $exitPoints] = $this->mergeForwardGotoExitPoints( + $labelName, + $scope, + $alreadyTerminated, + $exitPoints, + ); if ($alreadyTerminated) { continue; } if ($node->getAttribute(GotoLabelVisitor::HAS_BACKWARD_GOTO_ATTRIBUTE) === true) { - $originalStorage = $expressionResultStorage; - $bodyStmts = array_slice($stmts, $si + 1); - $bodyScope = $scope; - $count = 0; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - $tempStorage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal( - $dummyParent, - $bodyStmts, - $bodyScope, - $tempStorage, - new NoopNodeCallback(), - StatementContext::createDeep(), - ); - - $gotoScope = null; - foreach ($bodyScopeResult->getExitPoints() as $ep) { - $epStmt = $ep->getStatement(); - if (!($epStmt instanceof Goto_) || $epStmt->name->toString() !== $labelName) { - continue; - } - - $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); - } - - if ($gotoScope !== null) { - $bodyScope = $scope->mergeWith($gotoScope); - } - - if ($bodyScope->equals($prevScope)) { - break; - } - - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - - $scope = $bodyScope; - $expressionResultStorage = $originalStorage; + $scope = $this->resolveBackwardGotoScope( + $dummyParent, + array_slice($stmts, $si + 1), + $scope, + $expressionResultStorage, + StatementContext::createDeep(), + static fn (string $name): bool => $name === $labelName, + true, + ); } } @@ -437,6 +362,93 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void { } + /** + * @param Node\Stmt[] $bodyStmts + * @param Closure(string): bool $gotoNameMatcher + */ + private function resolveBackwardGotoScope( + Node $parentNode, + array $bodyStmts, + MutatingScope $scope, + ExpressionResultStorage $storage, + StatementContext $context, + Closure $gotoNameMatcher, + bool $mergeBodyScopeEachIteration, + ): MutatingScope + { + $bodyScope = $scope; + $count = 0; + do { + $prevScope = $bodyScope; + if ($mergeBodyScopeEachIteration) { + $bodyScope = $bodyScope->mergeWith($scope); + } + $tempStorage = $storage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal( + $parentNode, + $bodyStmts, + $bodyScope, + $tempStorage, + new NoopNodeCallback(), + $context, + ); + + $gotoScope = null; + foreach ($bodyScopeResult->getExitPoints() as $ep) { + $epStmt = $ep->getStatement(); + if (!($epStmt instanceof Goto_) || !$gotoNameMatcher($epStmt->name->toString())) { + continue; + } + + $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); + } + + if ($gotoScope !== null) { + $bodyScope = $scope->mergeWith($gotoScope); + } + + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + return $bodyScope; + } + + /** + * @param InternalStatementExitPoint[] $exitPoints + * @return array{MutatingScope, bool, list} + */ + private function mergeForwardGotoExitPoints( + string $labelName, + MutatingScope $scope, + bool $alreadyTerminated, + array $exitPoints, + ): array + { + $newExitPoints = []; + foreach ($exitPoints as $exitPoint) { + $exitStmt = $exitPoint->getStatement(); + if ($exitStmt instanceof Goto_ && $exitStmt->name->toString() === $labelName) { + if ($alreadyTerminated) { + $scope = $exitPoint->getScope(); + $alreadyTerminated = false; + } else { + $scope = $scope->mergeWith($exitPoint->getScope()); + } + } else { + $newExitPoints[] = $exitPoint; + } + } + + return [$scope, $alreadyTerminated, $newExitPoints]; + } + /** * @param Node\Stmt[] $nextStmts * @param callable(Node $node, Scope $scope): void $nodeCallback @@ -549,47 +561,15 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( $nestedLabelNames = $stmt->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE); if ($nestedLabelNames !== null && $context->isTopLevel()) { - $originalStorage = $storage; - $bodyScope = $scope; - $count = 0; - do { - $prevScope = $bodyScope; - $tempStorage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal( - $parentNode, - [$stmt], - $bodyScope, - $tempStorage, - new NoopNodeCallback(), - $context->enterDeep(), - ); - - $gotoScope = null; - foreach ($bodyScopeResult->getExitPoints() as $ep) { - $epStmt = $ep->getStatement(); - if (!($epStmt instanceof Goto_) || !isset($nestedLabelNames[$epStmt->name->toString()])) { - continue; - } - - $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); - } - - if ($gotoScope !== null) { - $bodyScope = $scope->mergeWith($gotoScope); - } - - if ($bodyScope->equals($prevScope)) { - break; - } - - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - - $scope = $bodyScope; - $storage = $originalStorage; + $scope = $this->resolveBackwardGotoScope( + $parentNode, + [$stmt], + $scope, + $storage, + $context->enterDeep(), + static fn (string $name): bool => isset($nestedLabelNames[$name]), + false, + ); } $statementResult = $this->processStmtNode( @@ -605,70 +585,27 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( if ($stmt instanceof Node\Stmt\Label) { $labelName = $stmt->name->toString(); - $newExitPoints = []; - foreach ($exitPoints as $exitPoint) { - $exitStmt = $exitPoint->getStatement(); - if ($exitStmt instanceof Goto_ && $exitStmt->name->toString() === $labelName) { - if ($alreadyTerminated) { - $scope = $exitPoint->getScope(); - $alreadyTerminated = false; - } else { - $scope = $scope->mergeWith($exitPoint->getScope()); - } - } else { - $newExitPoints[] = $exitPoint; - } - } - $exitPoints = $newExitPoints; + [$scope, $alreadyTerminated, $exitPoints] = $this->mergeForwardGotoExitPoints( + $labelName, + $scope, + $alreadyTerminated, + $exitPoints, + ); if ($alreadyTerminated) { continue; } if ($stmt->getAttribute(GotoLabelVisitor::HAS_BACKWARD_GOTO_ATTRIBUTE) === true && $context->isTopLevel()) { - $originalStorage = $storage; - $bodyStmts = array_slice($stmts, $i + 1); - $bodyScope = $scope; - $count = 0; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - $tempStorage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal( - $parentNode, - $bodyStmts, - $bodyScope, - $tempStorage, - new NoopNodeCallback(), - $context->enterDeep(), - ); - - $gotoScope = null; - foreach ($bodyScopeResult->getExitPoints() as $ep) { - $epStmt = $ep->getStatement(); - if (!($epStmt instanceof Goto_) || $epStmt->name->toString() !== $labelName) { - continue; - } - - $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); - } - - if ($gotoScope !== null) { - $bodyScope = $scope->mergeWith($gotoScope); - } - - if ($bodyScope->equals($prevScope)) { - break; - } - - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - - $scope = $bodyScope; - $storage = $originalStorage; + $scope = $this->resolveBackwardGotoScope( + $parentNode, + array_slice($stmts, $i + 1), + $scope, + $storage, + $context->enterDeep(), + static fn (string $name): bool => $name === $labelName, + true, + ); } }