From 15cf2c16880f3254b08674a751be74bf2182b742 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 01/10] 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 02/10] 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 03/10] 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 9c0425a9dc85c6d7c8138e3c13606b0757e3d947 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 09:34:22 +0000 Subject: [PATCH 04/10] 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 455c59fb12d075c7b25edd4dc26b5ed090864f6e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 09:45:55 +0000 Subject: [PATCH 05/10] 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 c3cdc7000cdf064f320492fc1e16a126501246c3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 13 Mar 2026 10:00:03 +0000 Subject: [PATCH 06/10] 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 61081f5db250b568b84c8ab9dd5bf9d9737a6aea Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 17:24:08 +0000 Subject: [PATCH 07/10] 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 08/10] 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 3980ed8f6d13c6a9debe19d33c764ccc6502eb33 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 15 Mar 2026 09:54:57 +0000 Subject: [PATCH 09/10] 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); } From 4a6544b3d7ed8138df61800a584a6b58287a9a4b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 17 Mar 2026 16:58:58 +0000 Subject: [PATCH 10/10] Rename isSimpleVariableReference() to isVariableToVariableReference() for clarity The new name better describes what the method checks: whether both sides of the intertwined reference link are plain named variables. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 +- src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 957fd5d0ad..1ea8c06dc3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2810,7 +2810,7 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require $exprExpr = $exprTypeHolder->getExpr(); if ( $exprExpr instanceof IntertwinedVariableByReferenceWithExpr - && $exprExpr->isSimpleVariableReference() + && $exprExpr->isVariableToVariableReference() ) { continue; } diff --git a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php index 3c9c523dbd..d23d7e4761 100644 --- a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php +++ b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php @@ -31,7 +31,7 @@ public function getAssignedExpr(): Expr return $this->assignedExpr; } - public function isSimpleVariableReference(): bool + public function isVariableToVariableReference(): bool { return $this->expr instanceof Variable && is_string($this->expr->name)