From ee7809ea7e690429ee15572b2d8788024bab3c2f 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/9] 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 76c40b1ae3..10b1f135eb 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/9] 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/9] 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 e60242af43..f80c8e24df 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2577,6 +2577,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; @@ -2594,6 +2595,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()), @@ -2610,6 +2612,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 b1793821c81d3042872b18de5d468947998a79e7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 09:34:22 +0000 Subject: [PATCH 4/9] 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 f80c8e24df..e71e1eafcb 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2577,8 +2577,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 5b16da07325d47fef328962cea34a2c05f604b9d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 09:45:55 +0000 Subject: [PATCH 5/9] 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 e71e1eafcb..f003bdb4fc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2565,7 +2565,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); @@ -2577,12 +2577,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; } @@ -2599,12 +2598,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( @@ -2616,31 +2615,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; } @@ -2831,6 +2805,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 61c54aeea1b7a22ec537fa38b5f2aff83464854c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 10:00:03 +0000 Subject: [PATCH 6/9] 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 f003bdb4fc..659578ceab 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2565,7 +2565,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); @@ -2577,10 +2580,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; @@ -2598,12 +2597,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 b7862138e9c929168641cc09f66d03a9405c4882 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 17:24:08 +0000 Subject: [PATCH 7/9] 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 @@ + Date: Sun, 15 Mar 2026 07:58:59 +0000 Subject: [PATCH 8/9] Refactor: extract isSimpleVariableReference() method on IntertwinedVariableByReferenceWithExpr Move the variable-type checking logic from MutatingScope::invalidateExpression() into a dedicated method for better readability. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 5 +---- src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 659578ceab..957fd5d0ad 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2810,10 +2810,7 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require $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) + && $exprExpr->isSimpleVariableReference() ) { continue; } diff --git a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php index 2b4358a4a6..75c0f7f0c2 100644 --- a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php +++ b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php @@ -29,6 +29,14 @@ public function getAssignedExpr(): Expr return $this->assignedExpr; } + public function isSimpleVariableReference(): bool + { + return $this->expr instanceof \PhpParser\Node\Expr\Variable + && is_string($this->expr->name) + && $this->assignedExpr instanceof \PhpParser\Node\Expr\Variable + && is_string($this->assignedExpr->name); + } + #[Override] public function getType(): string { From e171d49d61813fba6244dc12ce76f4f9a318a37d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 15 Mar 2026 09:54:57 +0000 Subject: [PATCH 9/9] Fix coding standards: use imports instead of fully qualified names in IntertwinedVariableByReferenceWithExpr Co-Authored-By: Claude Opus 4.6 --- src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php index 75c0f7f0c2..3c9c523dbd 100644 --- a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php +++ b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php @@ -4,7 +4,9 @@ use Override; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Variable; use PHPStan\Node\VirtualNode; +use function is_string; final class IntertwinedVariableByReferenceWithExpr extends Expr implements VirtualNode { @@ -31,9 +33,9 @@ public function getAssignedExpr(): Expr public function isSimpleVariableReference(): bool { - return $this->expr instanceof \PhpParser\Node\Expr\Variable + return $this->expr instanceof Variable && is_string($this->expr->name) - && $this->assignedExpr instanceof \PhpParser\Node\Expr\Variable + && $this->assignedExpr instanceof Variable && is_string($this->assignedExpr->name); }