From 2fe471269b128cc30d5bb56694a1d78e5c1e4115 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:30:32 +0000 Subject: [PATCH] Emit virtual `Assign` node for `List_` destructuring in `foreach` value position - When a `foreach` uses array destructuring in the value position (e.g. `foreach ($arr as ['key' => $val])`), `ArrayDestructuringRule` was never invoked because no `Assign` node was emitted for it to match. - In `NodeScopeResolver::processStmtNode`, alongside the existing `VariableAssignNode` emission for simple variable foreach values, emit a virtual `Assign(List_, GetIterableValueTypeExpr)` node when the value var is a `List_`. This causes `ArrayDestructuringRule` to fire and report nonexistent offsets. - The virtual node is emitted once before the loop convergence iterations, avoiding duplicate error reports. - Covers both `[]` and `list()` syntax, string/integer keys, and nested destructuring patterns. - Analogous cases probed and found not broken: foreach key destructuring (not valid PHP), regular assignment destructuring (already worked), assignment inside foreach body (already worked). --- src/Analyser/NodeScopeResolver.php | 4 ++ .../Arrays/ArrayDestructuringRuleTest.php | 34 ++++++++++ tests/PHPStan/Rules/Arrays/data/bug-8075.php | 64 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-8075.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f616b644ab3..596b73f29b8 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1240,6 +1240,10 @@ public function processStmtNode( if ($stmt->valueVar instanceof Variable) { $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->valueVar, new GetIterableValueTypeExpr($stmt->expr)), $originalScope, $storage); + } elseif ($stmt->valueVar instanceof List_) { + $virtualAssign = new Assign($stmt->valueVar, new GetIterableValueTypeExpr($stmt->expr)); + $virtualAssign->setAttributes($stmt->valueVar->getAttributes()); + $this->callNodeCallback($nodeCallback, $virtualAssign, $scope, $storage); } $originalStorage = $storage; diff --git a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php index 252ba1b61a1..2d1316f1b60 100644 --- a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php @@ -68,6 +68,40 @@ public function testBug14270(): void $this->analyse([__DIR__ . '/data/bug-14270.php'], []); } + public function testBug8075(): void + { + $this->analyse([__DIR__ . '/data/bug-8075.php'], [ + [ + 'Offset \'b\' does not exist on array{a: 0}.', + 12, + ], + [ + 'Offset \'b\' does not exist on array{a: 0}.', + 14, + ], + [ + 'Offset \'b\' does not exist on array{a: 0}.', + 17, + ], + [ + 'Offset \'b\' does not exist on array{a: 0}.', + 24, + ], + [ + 'Offset \'missing\' does not exist on array{name: string, age: int}.', + 36, + ], + [ + 'Offset 2 does not exist on array{string, int}.', + 48, + ], + [ + 'Offset \'z\' does not exist on array{x: int, y: int}.', + 60, + ], + ]); + } + #[RequiresPhp('>= 8.0.0')] public function testRuleWithNullsafeVariant(): void { diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8075.php b/tests/PHPStan/Rules/Arrays/data/bug-8075.php new file mode 100644 index 00000000000..fc20e67b984 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-8075.php @@ -0,0 +1,64 @@ + 0]]; + + ['b' => $val] = $arr[0]; // error - works + + foreach ($arr as ['b' => $valueB]) { // error - should be reported + } + + foreach ($arr as ['b' => $valueB, 'a' => $valueA]) { // error on 'b' + } + + foreach ($arr as ['a' => $valueA]) { // no error - 'a' exists + } + + foreach ($arr as $item) { + ['b' => $valueB] = $item; // error - works + } + } + + /** + * @param array $people + */ + public function doBar(array $people): void + { + foreach ($people as ['name' => $name, 'age' => $age]) { // no error + } + + foreach ($people as ['name' => $name, 'missing' => $missing]) { // error on 'missing' + } + } + + /** + * @param list $tuples + */ + public function doBaz(array $tuples): void + { + foreach ($tuples as [$first, $second]) { // no error + } + + foreach ($tuples as [$first, $second, $third]) { // error on offset 2 + } + } + + /** + * @param list $nested + */ + public function doNested(array $nested): void + { + foreach ($nested as ['a' => ['x' => $x, 'y' => $y]]) { // no error + } + + foreach ($nested as ['a' => ['x' => $x, 'z' => $z]]) { // error on 'z' + } + } + +}