From a7828a6bad074a33f778413ad365b119a504310c 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 8f0a2137c2..4d1a533ac5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2579,6 +2579,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; @@ -2596,6 +2597,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()), @@ -2612,6 +2614,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 a0ce7ccff84bd1ad8c1475568b0545c4b54b49bd 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 4d1a533ac5..7c8a407503 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2579,8 +2579,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 18648a0a2bb9307d953d6980f961d129236b69bf 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 7c8a407503..9a7c05304d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2567,7 +2567,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); @@ -2579,12 +2579,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; } @@ -2601,12 +2600,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( @@ -2618,31 +2617,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; } @@ -2856,6 +2830,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 e0e4157fc7e08ff35e594e20a9926112337d3ee1 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 9a7c05304d..3a9cce9448 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2567,7 +2567,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); @@ -2579,10 +2582,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; @@ -2600,12 +2599,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 3ded2f53d4c07f2838a0deb5f299103777fe1503 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 3a9cce9448..9be393a2f9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2835,10 +2835,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 511e73e836f924fbb5d77ff54cbfb1ccb1f03e2b 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 0827af5613212a43adc106450abe92d7f9c666a0 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 9be393a2f9..42fe96957d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2835,7 +2835,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)