From 4afa331e96d94cedfa8792aa02afde6ecf4e634e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:25:33 +0000 Subject: [PATCH 1/3] Fix anonymous class constructor throw points leaking inner scope - Throw points from anonymous class constructor body carried inner method scopes instead of the outer scope, causing variables defined before try blocks to be reported as "might not be defined" in finally blocks - Added replaceScope() method to InternalThrowPoint to allow scope replacement - New regression test in tests/PHPStan/Rules/Variables/data/bug-13920.php Closes https://github.com/phpstan/phpstan/issues/13920 --- src/Analyser/ExprHandler/NewHandler.php | 2 +- src/Analyser/InternalThrowPoint.php | 5 ++++ .../Variables/DefinedVariableRuleTest.php | 10 +++++++ .../Rules/Variables/data/bug-13920.php | 26 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13920.php diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 9e83f7c474..3190cf4832 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -190,7 +190,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $constructorResult = $node; }, StatementContext::createTopLevel()); if ($constructorResult !== null) { - $throwPoints = array_map(static fn (ThrowPoint $point) => InternalThrowPoint::createFromPublic($point), $constructorResult->getStatementResult()->getThrowPoints()); + $throwPoints = array_map(static fn (ThrowPoint $point): InternalThrowPoint => InternalThrowPoint::createFromPublic($point)->replaceScope($scope), $constructorResult->getStatementResult()->getThrowPoints()); $impurePoints = $constructorResult->getImpurePoints(); } } else { diff --git a/src/Analyser/InternalThrowPoint.php b/src/Analyser/InternalThrowPoint.php index 92291bd5ac..aaac06c8df 100644 --- a/src/Analyser/InternalThrowPoint.php +++ b/src/Analyser/InternalThrowPoint.php @@ -88,6 +88,11 @@ public function canContainAnyThrowable(): bool return $this->canContainAnyThrowable; } + public function replaceScope(MutatingScope $scope): self + { + return new self($scope, $this->type, $this->node, $this->explicit, $this->canContainAnyThrowable); + } + public function subtractCatchType(Type $catchType): self { return new self($this->scope, TypeCombinator::remove($this->type, $catchType), $this->node, $this->explicit, $this->canContainAnyThrowable); diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index add6c66beb..076dfc65f9 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1425,6 +1425,16 @@ public function testBug12373(): void $this->analyse([__DIR__ . '/data/bug-12373.php'], []); } + public function testBug13920(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-13920.php'], []); + } + public function testBug14117(): void { $this->cliArgumentsVariablesRegistered = true; diff --git a/tests/PHPStan/Rules/Variables/data/bug-13920.php b/tests/PHPStan/Rules/Variables/data/bug-13920.php new file mode 100644 index 0000000000..e21d129248 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13920.php @@ -0,0 +1,26 @@ + Date: Tue, 17 Mar 2026 17:11:15 +0000 Subject: [PATCH 2/3] Add scope parameter to InternalThrowPoint::createFromPublic instead of replaceScope Move the scope replacement into createFromPublic() as an optional parameter, removing the separate replaceScope() method as suggested in code review. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/NewHandler.php | 2 +- src/Analyser/InternalThrowPoint.php | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 3190cf4832..76b0126376 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -190,7 +190,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $constructorResult = $node; }, StatementContext::createTopLevel()); if ($constructorResult !== null) { - $throwPoints = array_map(static fn (ThrowPoint $point): InternalThrowPoint => InternalThrowPoint::createFromPublic($point)->replaceScope($scope), $constructorResult->getStatementResult()->getThrowPoints()); + $throwPoints = array_map(static fn (ThrowPoint $point): InternalThrowPoint => InternalThrowPoint::createFromPublic($point, $scope), $constructorResult->getStatementResult()->getThrowPoints()); $impurePoints = $constructorResult->getImpurePoints(); } } else { diff --git a/src/Analyser/InternalThrowPoint.php b/src/Analyser/InternalThrowPoint.php index aaac06c8df..9707f23d60 100644 --- a/src/Analyser/InternalThrowPoint.php +++ b/src/Analyser/InternalThrowPoint.php @@ -50,11 +50,13 @@ public static function createImplicit(MutatingScope $scope, Node $node): self return new self($scope, new ObjectType(Throwable::class), $node, explicit: false, canContainAnyThrowable: true); } - public static function createFromPublic(ThrowPoint $throwPoint): self + public static function createFromPublic(ThrowPoint $throwPoint, ?MutatingScope $scope = null): self { - $scope = $throwPoint->getScope(); - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); + if ($scope === null) { + $scope = $throwPoint->getScope(); + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } } return new self($scope, $throwPoint->getType(), $throwPoint->getNode(), $throwPoint->isExplicit(), $throwPoint->canContainAnyThrowable()); @@ -88,11 +90,6 @@ public function canContainAnyThrowable(): bool return $this->canContainAnyThrowable; } - public function replaceScope(MutatingScope $scope): self - { - return new self($scope, $this->type, $this->node, $this->explicit, $this->canContainAnyThrowable); - } - public function subtractCatchType(Type $catchType): self { return new self($this->scope, TypeCombinator::remove($this->type, $catchType), $this->node, $this->explicit, $this->canContainAnyThrowable); From 13e513f424a6b5ebe62af115dc591073fea9883b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 17 Mar 2026 17:19:16 +0000 Subject: [PATCH 3/3] Add scope parameter to InternalThrowPoint::createFromPublic instead of replaceScope Make the $scope parameter required (non-nullable) since it is always provided at the only call site. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/InternalThrowPoint.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Analyser/InternalThrowPoint.php b/src/Analyser/InternalThrowPoint.php index 9707f23d60..a50c4bd042 100644 --- a/src/Analyser/InternalThrowPoint.php +++ b/src/Analyser/InternalThrowPoint.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser; use PhpParser\Node; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -50,15 +49,8 @@ public static function createImplicit(MutatingScope $scope, Node $node): self return new self($scope, new ObjectType(Throwable::class), $node, explicit: false, canContainAnyThrowable: true); } - public static function createFromPublic(ThrowPoint $throwPoint, ?MutatingScope $scope = null): self + public static function createFromPublic(ThrowPoint $throwPoint, MutatingScope $scope): self { - if ($scope === null) { - $scope = $throwPoint->getScope(); - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - } - return new self($scope, $throwPoint->getType(), $throwPoint->getNode(), $throwPoint->isExplicit(), $throwPoint->canContainAnyThrowable()); }