From 73f28b15acd4d1360ce460ab3b9ccd269ffdff2d Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 14 May 2026 22:42:01 +0000 Subject: [PATCH 1/8] Respect `@throws void` on `getIterator()` when determining foreach Traversable throw points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `getTraversableForeachThrowPoint()` from inline implicit throw point creation in the `Foreach_` handler of `NodeScopeResolver` - For `IteratorAggregate` types, check `getIterator()` throw type: - `@throws void` → no throw point (iteration init cannot throw) - Explicit `@throws SomeException` → explicit throw point with that type - No annotation → fall through to implicit throw point - Also respect the `implicitThrows` config parameter, which was previously ignored for the foreach Traversable throw point (unlike all other implicit throw points in the codebase) - Probed analogous cases: direct `Iterator` types, union types, array foreach, `@throws` with non-matching catch types — all behave correctly --- src/Analyser/NodeScopeResolver.php | 34 ++++- tests/PHPStan/Analyser/nsrt/bug-6833.php | 107 ++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 19 +++ .../PHPStan/Rules/Variables/data/bug-6833.php | 118 ++++++++++++++++++ 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6833.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-6833.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 507987a2a51..a3c66bcf4a9 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4,6 +4,7 @@ use ArrayAccess; use Closure; +use IteratorAggregate; use Override; use PhpParser\Comment\Doc; use PhpParser\Modifiers; @@ -1437,8 +1438,9 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); } - if (!(new ObjectType(Traversable::class))->isSuperTypeOf($scope->getType($stmt->expr))->no()) { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $stmt->expr); + $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr); + if ($traversableThrowPoint !== null) { + $throwPoints[] = $traversableThrowPoint; } if ($context->isTopLevel() && $stmt->byRef) { $finalScope = $finalScope->assignExpression(new ForeachValueByRefExpr($stmt->valueVar), new MixedType(), new MixedType()); @@ -4031,6 +4033,34 @@ private function tryProcessUnrolledConstantArrayForeach( return ['bodyScope' => $bodyScope, 'endScope' => $endScope]; } + private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint + { + $exprType = $scope->getType($iteratee); + + if ((new ObjectType(Traversable::class))->isSuperTypeOf($exprType)->no()) { + return null; + } + + $iteratorAggregateType = new ObjectType(IteratorAggregate::class); + if ($iteratorAggregateType->isSuperTypeOf($exprType)->yes() && $exprType->hasMethod('getIterator')->yes()) { + $method = $exprType->getMethod('getIterator', $scope); + $throwType = $method->getThrowType(); + if ($throwType !== null) { + if ($throwType->isVoid()->yes()) { + return null; + } + + return InternalThrowPoint::createExplicit($scope, $throwType, $iteratee, true); + } + } + + if (!$this->implicitThrows) { + return null; + } + + return InternalThrowPoint::createImplicit($scope, $iteratee); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-6833.php b/tests/PHPStan/Analyser/nsrt/bug-6833.php new file mode 100644 index 00000000000..ccaa1378618 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6833.php @@ -0,0 +1,107 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug6833Nsrt; + +use PHPStan\TrinaryLogic; +use function PHPStan\Testing\assertVariableCertainty; + +class File +{ + public function __construct(private int $id) {} + public function getId(): int { return $this->id; } +} + +/** + * @implements \IteratorAggregate + */ +class FileCollectionThrowsVoid implements \IteratorAggregate +{ + /** @throws void */ + public function getIterator(): \Iterator + { + return new \ArrayIterator([]); + } +} + +/** + * @implements \IteratorAggregate + */ +class FileCollectionNoAnnotation implements \IteratorAggregate +{ + public function getIterator(): \Iterator + { + return new \ArrayIterator([]); + } +} + +/** + * @implements \IteratorAggregate + */ +class FileCollectionExplicitThrows implements \IteratorAggregate +{ + /** @throws \RuntimeException */ + public function getIterator(): \Iterator + { + return new \ArrayIterator([]); + } +} + +function testThrowsVoidCatchScope(FileCollectionThrowsVoid $files): void +{ + try { + foreach ($files as $file) { + doSomething(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createYes(), $file); + } +} + +function testNoAnnotationCatchScope(FileCollectionNoAnnotation $files): void +{ + try { + foreach ($files as $file) { + doSomething(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + } +} + +function testExplicitThrowsCatchScope(FileCollectionExplicitThrows $files): void +{ + try { + foreach ($files as $file) { + doSomething(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + } +} + +function testThrowsVoidFinallyScope(FileCollectionThrowsVoid $files): void +{ + try { + foreach ($files as $file) { + doSomething(); + } + } finally { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + } +} + +/** @param File[] $files */ +function testArrayCatchScope(array $files): void +{ + try { + foreach ($files as $file) { + doSomething(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createYes(), $file); + } +} + +function doSomething(): void {} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index b6cc5f03bc3..7e5c8f4a9f0 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1571,4 +1571,23 @@ public function testBug10729(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug6833(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-6833.php'], [ + [ + 'Variable $file might not be defined.', + 65, + ], + [ + 'Variable $file might not be defined.', + 91, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-6833.php b/tests/PHPStan/Rules/Variables/data/bug-6833.php new file mode 100644 index 00000000000..1831440d49f --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-6833.php @@ -0,0 +1,118 @@ += 8.0 + +declare(strict_types=1); + +namespace Bug6833; + +class File +{ + public function __construct(private int $id) {} + public function getId(): int { return $this->id; } +} + +/** + * @implements \IteratorAggregate + */ +class FileCollection implements \IteratorAggregate +{ + /** @var File[] */ + private array $files = []; + + public function add(File $file): void + { + $this->files[] = $file; + } + + /** @throws void */ + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->files); + } +} + +function testThrowsVoidOnGetIterator(FileCollection $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + echo 'Invalid file:' . $file->getId(); + } +} + +/** + * @implements \IteratorAggregate + */ +class FileCollectionWithoutThrowsVoid implements \IteratorAggregate +{ + /** @var File[] */ + private array $files = []; + + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->files); + } +} + +function testWithoutThrowsVoid(FileCollectionWithoutThrowsVoid $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + echo $file->getId(); // error - getIterator() could throw + } +} + +/** + * @implements \IteratorAggregate + */ +class FileCollectionExplicitThrows implements \IteratorAggregate +{ + /** @var File[] */ + private array $files = []; + + /** @throws \RuntimeException */ + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->files); + } +} + +function testExplicitThrowsMatchingCatch(FileCollectionExplicitThrows $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + echo $file->getId(); // error - getIterator() can throw RuntimeException + } +} + +function testExplicitThrowsNonMatchingCatch(FileCollectionExplicitThrows $files): void +{ + try { + foreach ($files as $file) { + if ($file->getId() < 0) { + throw new \LogicException('negative'); + } + } + } catch (\LogicException) { + echo $file->getId(); // no error - RuntimeException doesn't match LogicException catch + } +} + +/** @param File[] $files */ +function testArrayForeach(array $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + echo $file->getId(); // no error - arrays don't call getIterator() + } +} From 1fa4a25e32a28283bd1a4a601f2d3a49ec93f821 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 07:37:52 +0000 Subject: [PATCH 2/8] Merge bug-6833 test fixtures into a single file Co-Authored-By: Claude Opus 4.6 --- .../Analyser/NodeScopeResolverTest.php | 4 + tests/PHPStan/Analyser/nsrt/bug-6833.php | 107 ------------------ .../Variables/DefinedVariableRuleTest.php | 16 ++- .../PHPStan/Rules/Variables/data/bug-6833.php | 20 ++++ 4 files changed, 38 insertions(+), 109 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-6833.php diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index a8903a13df5..4452df223fc 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -284,6 +284,10 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-14124.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; + + if (PHP_VERSION_ID >= 80000) { + yield __DIR__ . '/../Rules/Variables/data/bug-6833.php'; + } } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-6833.php b/tests/PHPStan/Analyser/nsrt/bug-6833.php deleted file mode 100644 index ccaa1378618..00000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-6833.php +++ /dev/null @@ -1,107 +0,0 @@ -= 8.0 - -declare(strict_types = 1); - -namespace Bug6833Nsrt; - -use PHPStan\TrinaryLogic; -use function PHPStan\Testing\assertVariableCertainty; - -class File -{ - public function __construct(private int $id) {} - public function getId(): int { return $this->id; } -} - -/** - * @implements \IteratorAggregate - */ -class FileCollectionThrowsVoid implements \IteratorAggregate -{ - /** @throws void */ - public function getIterator(): \Iterator - { - return new \ArrayIterator([]); - } -} - -/** - * @implements \IteratorAggregate - */ -class FileCollectionNoAnnotation implements \IteratorAggregate -{ - public function getIterator(): \Iterator - { - return new \ArrayIterator([]); - } -} - -/** - * @implements \IteratorAggregate - */ -class FileCollectionExplicitThrows implements \IteratorAggregate -{ - /** @throws \RuntimeException */ - public function getIterator(): \Iterator - { - return new \ArrayIterator([]); - } -} - -function testThrowsVoidCatchScope(FileCollectionThrowsVoid $files): void -{ - try { - foreach ($files as $file) { - doSomething(); - } - } catch (\Throwable) { - assertVariableCertainty(TrinaryLogic::createYes(), $file); - } -} - -function testNoAnnotationCatchScope(FileCollectionNoAnnotation $files): void -{ - try { - foreach ($files as $file) { - doSomething(); - } - } catch (\Throwable) { - assertVariableCertainty(TrinaryLogic::createMaybe(), $file); - } -} - -function testExplicitThrowsCatchScope(FileCollectionExplicitThrows $files): void -{ - try { - foreach ($files as $file) { - doSomething(); - } - } catch (\Throwable) { - assertVariableCertainty(TrinaryLogic::createMaybe(), $file); - } -} - -function testThrowsVoidFinallyScope(FileCollectionThrowsVoid $files): void -{ - try { - foreach ($files as $file) { - doSomething(); - } - } finally { - assertVariableCertainty(TrinaryLogic::createMaybe(), $file); - } -} - -/** @param File[] $files */ -function testArrayCatchScope(array $files): void -{ - try { - foreach ($files as $file) { - doSomething(); - } - } catch (\Throwable) { - assertVariableCertainty(TrinaryLogic::createYes(), $file); - } -} - -function doSomething(): void {} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 7e5c8f4a9f0..0cb17436925 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1581,11 +1581,23 @@ public function testBug6833(): void $this->analyse([__DIR__ . '/data/bug-6833.php'], [ [ 'Variable $file might not be defined.', - 65, + 69, + ], + [ + 'Variable $file might not be defined.', + 70, + ], + [ + 'Variable $file might not be defined.', + 96, + ], + [ + 'Variable $file might not be defined.', + 97, ], [ 'Variable $file might not be defined.', - 91, + 134, ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-6833.php b/tests/PHPStan/Rules/Variables/data/bug-6833.php index 1831440d49f..8cc5cc6ad78 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-6833.php +++ b/tests/PHPStan/Rules/Variables/data/bug-6833.php @@ -4,6 +4,9 @@ namespace Bug6833; +use PHPStan\TrinaryLogic; +use function PHPStan\Testing\assertVariableCertainty; + class File { public function __construct(private int $id) {} @@ -37,6 +40,7 @@ function testThrowsVoidOnGetIterator(FileCollection $files): void echo $file->getId(); } } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createYes(), $file); echo 'Invalid file:' . $file->getId(); } } @@ -62,6 +66,7 @@ function testWithoutThrowsVoid(FileCollectionWithoutThrowsVoid $files): void echo $file->getId(); } } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); echo $file->getId(); // error - getIterator() could throw } } @@ -88,6 +93,7 @@ function testExplicitThrowsMatchingCatch(FileCollectionExplicitThrows $files): v echo $file->getId(); } } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); echo $file->getId(); // error - getIterator() can throw RuntimeException } } @@ -113,6 +119,20 @@ function testArrayForeach(array $files): void echo $file->getId(); } } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createYes(), $file); echo $file->getId(); // no error - arrays don't call getIterator() } } + +function testThrowsVoidFinallyScope(FileCollection $files): void +{ + try { + foreach ($files as $file) { + doSomething(); + } + } finally { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + } +} + +function doSomething(): void {} From f8807959084e5a1d0c4a94020aa5cd9c19c42333 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 07:44:28 +0000 Subject: [PATCH 3/8] Handle array|IteratorAggregate union types in foreach throw point detection Decompose union types to check each member individually. Non-Traversable members (like arrays) are skipped. IteratorAggregate members have their getIterator() @throws annotation checked. This ensures @throws void is respected even when the iteratee is a union like array|IteratorAggregate. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 42 ++++++++++++++----- .../Variables/DefinedVariableRuleTest.php | 16 +++++++ .../PHPStan/Rules/Variables/data/bug-6833.php | 39 +++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a3c66bcf4a9..e6314afb2e0 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4036,29 +4036,49 @@ private function tryProcessUnrolledConstantArrayForeach( private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint { $exprType = $scope->getType($iteratee); + $traversableType = new ObjectType(Traversable::class); - if ((new ObjectType(Traversable::class))->isSuperTypeOf($exprType)->no()) { + if ($traversableType->isSuperTypeOf($exprType)->no()) { return null; } $iteratorAggregateType = new ObjectType(IteratorAggregate::class); - if ($iteratorAggregateType->isSuperTypeOf($exprType)->yes() && $exprType->hasMethod('getIterator')->yes()) { - $method = $exprType->getMethod('getIterator', $scope); - $throwType = $method->getThrowType(); - if ($throwType !== null) { - if ($throwType->isVoid()->yes()) { - return null; + $innerTypes = $exprType instanceof UnionType ? $exprType->getTypes() : [$exprType]; + $needsImplicitThrowPoint = false; + $explicitThrowTypes = []; + + foreach ($innerTypes as $innerType) { + if ($traversableType->isSuperTypeOf($innerType)->no()) { + continue; + } + + if ($iteratorAggregateType->isSuperTypeOf($innerType)->yes() && $innerType->hasMethod('getIterator')->yes()) { + $method = $innerType->getMethod('getIterator', $scope); + $throwType = $method->getThrowType(); + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + $explicitThrowTypes[] = $throwType; + } + continue; } + } + + $needsImplicitThrowPoint = true; + } - return InternalThrowPoint::createExplicit($scope, $throwType, $iteratee, true); + if ($needsImplicitThrowPoint) { + if (!$this->implicitThrows) { + return null; } + + return InternalThrowPoint::createImplicit($scope, $iteratee); } - if (!$this->implicitThrows) { - return null; + if ($explicitThrowTypes !== []) { + return InternalThrowPoint::createExplicit($scope, TypeCombinator::union(...$explicitThrowTypes), $iteratee, true); } - return InternalThrowPoint::createImplicit($scope, $iteratee); + return null; } /** diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 0cb17436925..a483d19005d 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1599,6 +1599,22 @@ public function testBug6833(): void 'Variable $file might not be defined.', 134, ], + [ + 'Variable $file might not be defined.', + 159, + ], + [ + 'Variable $file might not be defined.', + 160, + ], + [ + 'Variable $file might not be defined.', + 172, + ], + [ + 'Variable $file might not be defined.', + 173, + ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-6833.php b/tests/PHPStan/Rules/Variables/data/bug-6833.php index 8cc5cc6ad78..9259dac5291 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-6833.php +++ b/tests/PHPStan/Rules/Variables/data/bug-6833.php @@ -135,4 +135,43 @@ function testThrowsVoidFinallyScope(FileCollection $files): void } } +/** @param File[]|FileCollection $files */ +function testArrayOrThrowsVoid(array|FileCollection $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createYes(), $file); + echo $file->getId(); // no error - array doesn't throw, getIterator() has @throws void + } +} + +/** @param File[]|FileCollectionExplicitThrows $files */ +function testArrayOrExplicitThrows(array|FileCollectionExplicitThrows $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + echo $file->getId(); // error - getIterator() can throw RuntimeException + } +} + +/** @param File[]|FileCollectionWithoutThrowsVoid $files */ +function testArrayOrNoAnnotation(array|FileCollectionWithoutThrowsVoid $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + echo $file->getId(); // error - getIterator() could throw + } +} + function doSomething(): void {} From a93196b3197c2dc86b4f0b626e9f10061d0a6b26 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 10:28:51 +0000 Subject: [PATCH 4/8] Use TypeCombinator::intersect instead of instanceof UnionType for foreach throw point detection Replace manual union decomposition (`$exprType instanceof UnionType ? $exprType->getTypes() : [...]`) with `TypeCombinator::intersect($exprType, $traversableType)` to extract the Traversable part, then check if it's entirely IteratorAggregate via `isSuperTypeOf`. This follows the codebase convention of never using `instanceof` to check types and lets the type system handle union/intersection decomposition internally. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 41 +++++++++--------------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e6314afb2e0..1f2c41062de 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4042,43 +4042,26 @@ private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $ite return null; } + $traversablePart = TypeCombinator::intersect($exprType, $traversableType); $iteratorAggregateType = new ObjectType(IteratorAggregate::class); - $innerTypes = $exprType instanceof UnionType ? $exprType->getTypes() : [$exprType]; - $needsImplicitThrowPoint = false; - $explicitThrowTypes = []; - foreach ($innerTypes as $innerType) { - if ($traversableType->isSuperTypeOf($innerType)->no()) { - continue; - } - - if ($iteratorAggregateType->isSuperTypeOf($innerType)->yes() && $innerType->hasMethod('getIterator')->yes()) { - $method = $innerType->getMethod('getIterator', $scope); - $throwType = $method->getThrowType(); - if ($throwType !== null) { - if (!$throwType->isVoid()->yes()) { - $explicitThrowTypes[] = $throwType; - } - continue; + if ($iteratorAggregateType->isSuperTypeOf($traversablePart)->yes() + && $traversablePart->hasMethod('getIterator')->yes()) { + $method = $traversablePart->getMethod('getIterator', $scope); + $throwType = $method->getThrowType(); + if ($throwType !== null) { + if ($throwType->isVoid()->yes()) { + return null; } + return InternalThrowPoint::createExplicit($scope, $throwType, $iteratee, true); } - - $needsImplicitThrowPoint = true; - } - - if ($needsImplicitThrowPoint) { - if (!$this->implicitThrows) { - return null; - } - - return InternalThrowPoint::createImplicit($scope, $iteratee); } - if ($explicitThrowTypes !== []) { - return InternalThrowPoint::createExplicit($scope, TypeCombinator::union(...$explicitThrowTypes), $iteratee, true); + if (!$this->implicitThrows) { + return null; } - return null; + return InternalThrowPoint::createImplicit($scope, $iteratee); } /** From 4a73b176fc5f19caa923233f58fbf72b6270550d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 11:07:03 +0000 Subject: [PATCH 5/8] Move implicitThrows check inside IteratorAggregate block The implicitThrows config should only gate the no-annotation case for IteratorAggregate types. For non-IteratorAggregate Traversables, the implicit throw point should always be added since there is no getIterator() to inspect. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1f2c41062de..1f0b7a4cb09 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4055,10 +4055,10 @@ private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $ite } return InternalThrowPoint::createExplicit($scope, $throwType, $iteratee, true); } - } - if (!$this->implicitThrows) { - return null; + if (!$this->implicitThrows) { + return null; + } } return InternalThrowPoint::createImplicit($scope, $iteratee); From d84e9ca2e3469422b5b2da52a1f98eb7fd4adb9c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 11:18:57 +0000 Subject: [PATCH 6/8] Use early exit for PHP version check in NodeScopeResolverTest Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/NodeScopeResolverTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 4452df223fc..822e80f0141 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -285,9 +285,11 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; - if (PHP_VERSION_ID >= 80000) { - yield __DIR__ . '/../Rules/Variables/data/bug-6833.php'; + if (PHP_VERSION_ID < 80000) { + return; } + + yield __DIR__ . '/../Rules/Variables/data/bug-6833.php'; } /** From b36bf58e0048cb4223bb9af97ef204d82ffcb88d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 11:47:37 +0000 Subject: [PATCH 7/8] Move bug-6833 test fixture to nsrt folder The test file belongs in tests/PHPStan/Analyser/nsrt/ since it contains assertVariableCertainty() calls. The nsrt directory auto-discovers test files, removing the need for the manual yield and PHP version guard in NodeScopeResolverTest. The DefinedVariableRuleTest path is updated to reference the new location. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/NodeScopeResolverTest.php | 6 ------ .../{Rules/Variables/data => Analyser/nsrt}/bug-6833.php | 0 tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php | 2 +- 3 files changed, 1 insertion(+), 7 deletions(-) rename tests/PHPStan/{Rules/Variables/data => Analyser/nsrt}/bug-6833.php (100%) diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 822e80f0141..a8903a13df5 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -284,12 +284,6 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-14124.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; - - if (PHP_VERSION_ID < 80000) { - return; - } - - yield __DIR__ . '/../Rules/Variables/data/bug-6833.php'; } /** diff --git a/tests/PHPStan/Rules/Variables/data/bug-6833.php b/tests/PHPStan/Analyser/nsrt/bug-6833.php similarity index 100% rename from tests/PHPStan/Rules/Variables/data/bug-6833.php rename to tests/PHPStan/Analyser/nsrt/bug-6833.php diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index a483d19005d..19b44eb3046 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1578,7 +1578,7 @@ public function testBug6833(): void $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-6833.php'], [ + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-6833.php'], [ [ 'Variable $file might not be defined.', 69, From b2409b8a57497b00e51a7d7679ca71fcd2b5ae3d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 15 May 2026 14:31:29 +0200 Subject: [PATCH 8/8] another test --- tests/PHPStan/Analyser/nsrt/bug-6833.php | 31 +++++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 8 +++++ 2 files changed, 39 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6833.php b/tests/PHPStan/Analyser/nsrt/bug-6833.php index 9259dac5291..3affcd9c6ec 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6833.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6833.php @@ -175,3 +175,34 @@ function testArrayOrNoAnnotation(array|FileCollectionWithoutThrowsVoid $files): } function doSomething(): void {} + +/** + * @implements \IteratorAggregate + */ +class MaybeThrowingCollection implements \IteratorAggregate +{ + /** @var File[] */ + private array $files = []; + + public function add(File $file): void + { + $this->files[] = $file; + } + + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->files); + } +} + +function testUnionThrowsMatchingCatch(FileCollectionExplicitThrows|MaybeThrowingCollection $files): void +{ + try { + foreach ($files as $file) { + echo $file->getId(); + } + } catch (\Throwable) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $file); + echo $file->getId(); // error - getIterator() can throw RuntimeException + } +} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 19b44eb3046..67f77fa6bfe 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1615,6 +1615,14 @@ public function testBug6833(): void 'Variable $file might not be defined.', 173, ], + [ + 'Variable $file might not be defined.', + 205, + ], + [ + 'Variable $file might not be defined.', + 206, + ], ]); }