From 4f744d6e06d07fdba3ffbcffa2af7e26179417c8 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:40:59 +0000 Subject: [PATCH 1/7] Fix phpstan/phpstan#14275: Propagate type changes through variable references - Register bidirectional IntertwinedVariableByReferenceWithExpr entries when processing AssignRef between two simple variables - When $b = &$a, modifying $b now updates $a's type and vice versa - Reuses existing IntertwinedVariableByReferenceWithExpr mechanism that was already used for foreach-by-reference - New regression test in tests/PHPStan/Analyser/nsrt/bug-14275.php --- src/Analyser/ExprHandler/AssignHandler.php | 29 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14275.php | 27 ++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14275.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 284ec47619..059938d720 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -36,6 +36,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\GetOffsetValueTypeExpr; +use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; @@ -150,6 +151,34 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex true, ); $scope = $result->getScope(); + + if ( + $expr instanceof AssignRef + && $expr->var instanceof Variable + && is_string($expr->var->name) + && $expr->expr instanceof Variable + && is_string($expr->expr->name) + ) { + $varName = $expr->var->name; + $refName = $expr->expr->name; + $type = $scope->getType($expr->var); + $nativeType = $scope->getNativeType($expr->var); + + // When $varName is assigned, update $refName + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($varName, new Variable($refName), new Variable($varName)), + $type, + $nativeType, + ); + + // When $refName is assigned, update $varName + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($refName, new Variable($varName), new Variable($refName)), + $type, + $nativeType, + ); + } + $vars = $nodeScopeResolver->getAssignedVariables($expr->var); if (count($vars) > 0) { $varChangedScope = false; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14275.php b/tests/PHPStan/Analyser/nsrt/bug-14275.php new file mode 100644 index 0000000000..6351663940 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14275.php @@ -0,0 +1,27 @@ + Date: Fri, 13 Mar 2026 08:50:21 +0000 Subject: [PATCH 2/7] Add regression test for phpstan/phpstan#8056 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-8056.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8056.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-8056.php b/tests/PHPStan/Analyser/nsrt/bug-8056.php new file mode 100644 index 0000000000..d1f23862a8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8056.php @@ -0,0 +1,16 @@ + Date: Fri, 13 Mar 2026 09:29:57 +0000 Subject: [PATCH 3/7] Re-register intertwined variable entries after propagation for subsequent assignments When a reference like `$f = &$e` is created, intertwined entries are registered so that assigning to one variable updates the other. However, the `invalidateExpression` mechanism removed these entries during propagation, so only the first assignment after the reference was created would propagate. This fix re-registers both directions of the intertwined entries after each propagation, ensuring that subsequent assignments (e.g. `$e = 22` after `$f = 42`) continue to update the linked variable. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 27 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14275.php | 9 ++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index aaa4d34645..fb7d2b0da5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2647,6 +2647,7 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } + $processedIntertwinedEntries = []; foreach ($scope->expressionTypes as $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { continue; @@ -2664,6 +2665,7 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp && is_string($expressionType->getExpr()->getExpr()->name) && !$has->no() ) { + $processedIntertwinedEntries[] = $expressionType->getExpr(); $scope = $scope->assignVariable( $expressionType->getExpr()->getExpr()->name, $scope->getType($expressionType->getExpr()->getAssignedExpr()), @@ -2680,6 +2682,31 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } + // Re-register intertwined entries (and their reverse) that were + // invalidated during propagation so that subsequent assignments + // to either variable continue to propagate correctly. + foreach ($processedIntertwinedEntries as $intertwinedExpr) { + $currentType = $scope->getType($intertwinedExpr->getAssignedExpr()); + $currentNativeType = $scope->getNativeType($intertwinedExpr->getAssignedExpr()); + + // Re-register this direction + $scope = $scope->assignExpression($intertwinedExpr, $currentType, $currentNativeType); + + // Re-register the reverse direction + if ( + $intertwinedExpr->getExpr() instanceof Variable + && is_string($intertwinedExpr->getExpr()->name) + ) { + $linkedVarName = $intertwinedExpr->getExpr()->name; + $reverseExpr = new IntertwinedVariableByReferenceWithExpr( + $linkedVarName, + new Variable($variableName), + new Variable($linkedVarName), + ); + $scope = $scope->assignExpression($reverseExpr, $currentType, $currentNativeType); + } + } + return $scope; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14275.php b/tests/PHPStan/Analyser/nsrt/bug-14275.php index 6351663940..0f37de401d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14275.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14275.php @@ -25,3 +25,12 @@ $f = 42; assertType('42', $e); assertType('42', $f); + +// Subsequent assignments should continue propagating +$e = 22; +assertType('22', $e); +assertType('22', $f); + +$f = 33; +assertType('33', $e); +assertType('33', $f); From 504414a820512ae33d0ecf9a7e3476cad554043a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 09:34:22 +0000 Subject: [PATCH 4/7] Fix reference type propagation for subsequent assignments When $b = &$a creates a bidirectional reference link, assigning to either variable should propagate the type to the other for ALL subsequent assignments, not just the first one. The issue was that invalidateExpression() removed the reverse-direction intertwined entries when a variable was assigned, because the entries' sub-nodes contained the assigned variable. This fix preserves those entries by collecting them before invalidation and restoring reverse-direction entries after propagation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fb7d2b0da5..7a8c633882 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2647,8 +2647,12 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } + // Use $this->expressionTypes (pre-invalidation) to find intertwined entries. + // assignExpression() calls invalidateExpression() which removes entries whose + // sub-nodes contain the variable being assigned. For bidirectional reference + // links ($b = &$a), this incorrectly removes the reverse entry. $processedIntertwinedEntries = []; - foreach ($scope->expressionTypes as $expressionType) { + foreach ($this->expressionTypes as $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { continue; } From e89130eb7b9f1794981b61e651dd7a41f4459551 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 09:45:55 +0000 Subject: [PATCH 5/7] Simplify reference propagation: prevent invalidation instead of re-registering Replace the re-register-after-propagation approach with a cleaner design: - Skip invalidation of simple variable-to-variable IntertwinedVariableByReferenceWithExpr entries in invalidateExpression() so reference links persist across assignments - Add propagateReferences parameter to assignVariable() to prevent infinite recursion when propagating types through bidirectional reference links This is simpler and more efficient than the previous approach of letting entries be invalidated and then re-creating them after each propagation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 49 +++++++++++----------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7a8c633882..43d608f2b0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2635,7 +2635,7 @@ public function isUndefinedExpressionAllowed(Expr $expr): bool return array_key_exists($exprString, $this->currentlyAllowedUndefinedExpressions); } - public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty): self + public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, bool $propagateReferences = true): self { $node = new Variable($variableName); $scope = $this->assignExpression($node, $type, $nativeType); @@ -2647,12 +2647,11 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } - // Use $this->expressionTypes (pre-invalidation) to find intertwined entries. - // assignExpression() calls invalidateExpression() which removes entries whose - // sub-nodes contain the variable being assigned. For bidirectional reference - // links ($b = &$a), this incorrectly removes the reverse entry. - $processedIntertwinedEntries = []; - foreach ($this->expressionTypes as $expressionType) { + if (!$propagateReferences) { + return $scope; + } + + foreach ($scope->expressionTypes as $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { continue; } @@ -2669,12 +2668,12 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp && is_string($expressionType->getExpr()->getExpr()->name) && !$has->no() ) { - $processedIntertwinedEntries[] = $expressionType->getExpr(); $scope = $scope->assignVariable( $expressionType->getExpr()->getExpr()->name, $scope->getType($expressionType->getExpr()->getAssignedExpr()), $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), $has, + false, ); } else { $scope = $scope->assignExpression( @@ -2686,31 +2685,6 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } - // Re-register intertwined entries (and their reverse) that were - // invalidated during propagation so that subsequent assignments - // to either variable continue to propagate correctly. - foreach ($processedIntertwinedEntries as $intertwinedExpr) { - $currentType = $scope->getType($intertwinedExpr->getAssignedExpr()); - $currentNativeType = $scope->getNativeType($intertwinedExpr->getAssignedExpr()); - - // Re-register this direction - $scope = $scope->assignExpression($intertwinedExpr, $currentType, $currentNativeType); - - // Re-register the reverse direction - if ( - $intertwinedExpr->getExpr() instanceof Variable - && is_string($intertwinedExpr->getExpr()->name) - ) { - $linkedVarName = $intertwinedExpr->getExpr()->name; - $reverseExpr = new IntertwinedVariableByReferenceWithExpr( - $linkedVarName, - new Variable($variableName), - new Variable($linkedVarName), - ); - $scope = $scope->assignExpression($reverseExpr, $currentType, $currentNativeType); - } - } - return $scope; } @@ -2901,6 +2875,15 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require foreach ($expressionTypes as $exprString => $exprTypeHolder) { $exprExpr = $exprTypeHolder->getExpr(); + if ( + $exprExpr instanceof IntertwinedVariableByReferenceWithExpr + && $exprExpr->getExpr() instanceof Variable + && is_string($exprExpr->getExpr()->name) + && $exprExpr->getAssignedExpr() instanceof Variable + && is_string($exprExpr->getAssignedExpr()->name) + ) { + continue; + } if (!$this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $exprString, $requireMoreCharacters, $invalidatingClass)) { continue; } From fc6623fafe95641fc67850c73f9df99f2eb7d18c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 10:00:03 +0000 Subject: [PATCH 6/7] Fix reference propagation to use chain tracking instead of boolean flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boolean propagateReferences=false approach blocks ALL intertwined propagation in recursive calls, breaking nested foreach-by-reference (e.g. bug-13676 where value→row→rows must chain). Replace with intertwinedPropagatedFrom: a list of variable names already visited in the current propagation chain. This prevents circular back-propagation (A→B→A) while allowing legitimate chains (value→row→rows). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 43d608f2b0..2c4b53cee2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2635,7 +2635,10 @@ public function isUndefinedExpressionAllowed(Expr $expr): bool return array_key_exists($exprString, $this->currentlyAllowedUndefinedExpressions); } - public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, bool $propagateReferences = true): self + /** + * @param list $intertwinedPropagatedFrom + */ + public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, array $intertwinedPropagatedFrom = []): self { $node = new Variable($variableName); $scope = $this->assignExpression($node, $type, $nativeType); @@ -2647,10 +2650,6 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } - if (!$propagateReferences) { - return $scope; - } - foreach ($scope->expressionTypes as $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { continue; @@ -2668,12 +2667,16 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp && is_string($expressionType->getExpr()->getExpr()->name) && !$has->no() ) { + $targetVarName = $expressionType->getExpr()->getExpr()->name; + if (in_array($targetVarName, $intertwinedPropagatedFrom, true)) { + continue; + } $scope = $scope->assignVariable( - $expressionType->getExpr()->getExpr()->name, + $targetVarName, $scope->getType($expressionType->getExpr()->getAssignedExpr()), $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), $has, - false, + array_merge($intertwinedPropagatedFrom, [$variableName]), ); } else { $scope = $scope->assignExpression( From 102976387269beaf8a76022fcc18b9a9d91645cf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 17:24:08 +0000 Subject: [PATCH 7/7] Add rule test for bug 8056 to verify no false positive "Empty array passed to foreach" Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php | 5 +++++ tests/PHPStan/Rules/Arrays/data/bug-8056.php | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-8056.php diff --git a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php index 9e272b5802..227bcc3e4f 100644 --- a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php @@ -55,4 +55,9 @@ public function testBug2457(): void $this->analyse([__DIR__ . '/data/bug-2457.php'], []); } + public function testBug8056(): void + { + $this->analyse([__DIR__ . '/data/bug-8056.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8056.php b/tests/PHPStan/Rules/Arrays/data/bug-8056.php new file mode 100644 index 0000000000..ff28621d83 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-8056.php @@ -0,0 +1,11 @@ +