From 59ca979468e11a77876213f131b98ea5dc66be4c Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:52:50 +0000 Subject: [PATCH 01/15] Fix phpstan/phpstan#14333: Setting an array key doesn't update a reference - When creating an array with by-reference items (e.g. $b = ['key' => &$a]), register IntertwinedVariableByReferenceWithExpr entries so that subsequent assignments to array offsets propagate type changes to the referenced variables - Preserve non-variable-to-variable intertwined refs in assignVariable() so they survive the recursive propagation chain without being invalidated - New regression test in tests/PHPStan/Analyser/nsrt/bug-14333.php --- src/Analyser/ExprHandler/AssignHandler.php | 52 ++++++++++++++++++++++ src/Analyser/MutatingScope.php | 39 ++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14333.php | 34 ++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14333.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 10b1f135eb..0ee157b9fe 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -69,6 +69,7 @@ use function array_slice; use function count; use function in_array; +use function is_int; use function is_string; /** @@ -315,6 +316,57 @@ public function processAssignVar( foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions($exprString, $holders); } + + if ($assignedExpr instanceof Expr\Array_) { + $implicitIndex = 0; + foreach ($assignedExpr->items as $arrayItem) { + if ($arrayItem->key !== null) { + $keyType = $scope->getType($arrayItem->key); + if ($keyType->isConstantScalarValue()->yes()) { + $keyValues = $keyType->getConstantScalarValues(); + if (count($keyValues) === 1) { + $keyValue = $keyValues[0]; + if (is_int($keyValue) && $keyValue >= $implicitIndex) { + $implicitIndex = $keyValue + 1; + } + } + } + } + + if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { + if ($arrayItem->key === null) { + $implicitIndex++; + } + continue; + } + + $refVarName = $arrayItem->value->name; + if ($arrayItem->key !== null) { + $dimExpr = $arrayItem->key; + } else { + $dimExpr = new Node\Scalar\Int_($implicitIndex); + $implicitIndex++; + } + + $dimFetchExpr = new ArrayDimFetch(new Variable($var->name), $dimExpr); + $refType = $scope->getType(new Variable($refVarName)); + $refNativeType = $scope->getNativeType(new Variable($refVarName)); + + // When $varName's array key changes, update $refVarName + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($var->name, new Variable($refVarName), $dimFetchExpr), + $refType, + $refNativeType, + ); + + // When $refVarName changes, update $varName's array key + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($refVarName, $dimFetchExpr, new Variable($refVarName)), + $refType, + $refNativeType, + ); + } + } } else { $nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); $hasYield = $hasYield || $nameExprResult->hasYield(); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 42fe96957d..11309ef418 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2573,6 +2573,29 @@ public function isUndefinedExpressionAllowed(Expr $expr): bool public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, array $intertwinedPropagatedFrom = []): self { $node = new Variable($variableName); + + // Collect non-variable-to-variable intertwined refs for this variable before invalidation, + // as they may be lost during assignExpression and recursive propagation + $preservedIntertwinedRefs = []; + $preservedNativeIntertwinedRefs = []; + foreach ($this->expressionTypes as $exprString => $exprTypeHolder) { + $exprExpr = $exprTypeHolder->getExpr(); + if ( + !($exprExpr instanceof IntertwinedVariableByReferenceWithExpr) + || $exprExpr->getVariableName() !== $variableName + || $exprExpr->isVariableToVariableReference() + ) { + continue; + } + + $preservedIntertwinedRefs[$exprString] = $exprTypeHolder; + if (!array_key_exists($exprString, $this->nativeExpressionTypes)) { + continue; + } + + $preservedNativeIntertwinedRefs[$exprString] = $this->nativeExpressionTypes[$exprString]; + } + $scope = $this->assignExpression($node, $type, $nativeType); if ($certainty->no()) { throw new ShouldNotHappenException(); @@ -2620,6 +2643,22 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } + // Re-add intertwined refs that were lost during propagation + foreach ($preservedIntertwinedRefs as $exprString => $exprTypeHolder) { + if (array_key_exists($exprString, $scope->expressionTypes)) { + continue; + } + + $scope->expressionTypes[$exprString] = $exprTypeHolder; + } + foreach ($preservedNativeIntertwinedRefs as $exprString => $exprTypeHolder) { + if (array_key_exists($exprString, $scope->nativeExpressionTypes)) { + continue; + } + + $scope->nativeExpressionTypes[$exprString] = $exprTypeHolder; + } + return $scope; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php new file mode 100644 index 0000000000..005bc71881 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -0,0 +1,34 @@ + &$a]; + assertType("'hello'", $a); + + $b['key'] = 42; + assertType('42', $a); +} + +function testMultipleByRefInArray(): void +{ + $a = 1; + $c = 'test'; + + $b = [&$a, 'normal', &$c]; + assertType('1', $a); + assertType("'test'", $c); + + $b[0] = 2; + $b[1] = 'foo'; + $b[2] = 'bar'; + + assertType('2', $a); + assertType("'bar'", $c); +} From 85e52c5feb5bab2ff96296d683f16632d43b852c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 11:13:30 +0000 Subject: [PATCH 02/15] Add test for nested array reference tracking limitation Nested arrays like `[[&$a]]` don't propagate reference updates through `$b[0][0] = 2` to `$a`. This documents the current behavior as a known limitation of the array reference tracking implementation. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14333.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index 005bc71881..7720920b9e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -32,3 +32,23 @@ function testMultipleByRefInArray(): void assertType('2', $a); assertType("'bar'", $c); } + +function testNested(): void +{ + $a = 1; + + $b = [[&$a]]; + assertType('1', $a); + + $b[0][0] = 2; + + assertType('1', $a); // Should be 2 in real PHP, but nested array reference tracking is not implemented + + $b[0] = []; + + assertType('1', $a); + + $b[0][0] = 3; + + assertType('1', $a); +} From 40bab7a9780c231bc1e5af75bd6d926a8f032bd7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 11:46:46 +0000 Subject: [PATCH 03/15] Skip implicit-key by-ref tracking when index is uncertain due to non-constant keys When a non-constant key appears in an array literal, the implicit index counter becomes unreliable. Stop setting up intertwined refs for implicit-keyed by-ref items after that point, since we can't know their actual indices. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignHandler.php | 10 +++++++++- tests/PHPStan/Analyser/nsrt/bug-14333.php | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 0ee157b9fe..b3b499e37d 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -319,6 +319,7 @@ public function processAssignVar( if ($assignedExpr instanceof Expr\Array_) { $implicitIndex = 0; + $implicitIndexCertain = true; foreach ($assignedExpr->items as $arrayItem) { if ($arrayItem->key !== null) { $keyType = $scope->getType($arrayItem->key); @@ -329,7 +330,11 @@ public function processAssignVar( if (is_int($keyValue) && $keyValue >= $implicitIndex) { $implicitIndex = $keyValue + 1; } + } else { + $implicitIndexCertain = false; } + } else { + $implicitIndexCertain = false; } } @@ -343,9 +348,12 @@ public function processAssignVar( $refVarName = $arrayItem->value->name; if ($arrayItem->key !== null) { $dimExpr = $arrayItem->key; - } else { + } elseif ($implicitIndexCertain) { $dimExpr = new Node\Scalar\Int_($implicitIndex); $implicitIndex++; + } else { + $implicitIndex++; + continue; } $dimFetchExpr = new ArrayDimFetch(new Variable($var->name), $dimExpr); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index 7720920b9e..2ea94640e1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -33,6 +33,21 @@ function testMultipleByRefInArray(): void assertType("'bar'", $c); } +function testNonConstantKeyBreaksImplicitIndex(int $key): void +{ + $a = 1; + $c = 'test'; + + $b = [$key => 'x', &$a, &$c]; + assertType('1', $a); + assertType("'test'", $c); + + // Since $key is non-constant, we don't know the implicit indices of &$a and &$c + // so we can't track the reference propagation + $b[0] = 2; + assertType('1', $a); +} + function testNested(): void { $a = 1; From 1791c8b6363a9ca1a86e1cf242d4d31d3ba0c292 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 20 Mar 2026 12:52:40 +0100 Subject: [PATCH 04/15] Simplify --- src/Analyser/ExprHandler/AssignHandler.php | 25 +++++++--------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index b3b499e37d..fd2124e032 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -319,27 +319,19 @@ public function processAssignVar( if ($assignedExpr instanceof Expr\Array_) { $implicitIndex = 0; - $implicitIndexCertain = true; foreach ($assignedExpr->items as $arrayItem) { - if ($arrayItem->key !== null) { - $keyType = $scope->getType($arrayItem->key); - if ($keyType->isConstantScalarValue()->yes()) { - $keyValues = $keyType->getConstantScalarValues(); - if (count($keyValues) === 1) { - $keyValue = $keyValues[0]; - if (is_int($keyValue) && $keyValue >= $implicitIndex) { - $implicitIndex = $keyValue + 1; - } - } else { - $implicitIndexCertain = false; + if ($arrayItem->key !== null && $implicitIndex !== null) { + $keyValues = $scope->getType($arrayItem->key)->getConstantScalarValues(); + if (count($keyValues) === 1) { + $keyValue = $keyValues[0]; + if (is_int($keyValue) && $keyValue >= $implicitIndex) { + $implicitIndex = $keyValue + 1; } - } else { - $implicitIndexCertain = false; } } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { - if ($arrayItem->key === null) { + if ($arrayItem->key === null && $implicitIndex !== null) { $implicitIndex++; } continue; @@ -348,11 +340,10 @@ public function processAssignVar( $refVarName = $arrayItem->value->name; if ($arrayItem->key !== null) { $dimExpr = $arrayItem->key; - } elseif ($implicitIndexCertain) { + } elseif ($implicitIndex !== null) { $dimExpr = new Node\Scalar\Int_($implicitIndex); $implicitIndex++; } else { - $implicitIndex++; continue; } From 9ba6411ddaae16849e9baad142c02709de8161ab Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 20 Mar 2026 12:55:27 +0100 Subject: [PATCH 05/15] Fix --- src/Analyser/ExprHandler/AssignHandler.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index fd2124e032..37ccab986d 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -327,6 +327,8 @@ public function processAssignVar( if (is_int($keyValue) && $keyValue >= $implicitIndex) { $implicitIndex = $keyValue + 1; } + } else { + $implicitIndex = null; } } From 24078955d849d593026215d430b6e6be6a59e842 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 12:08:16 +0000 Subject: [PATCH 06/15] Implement nested array reference tracking When a variable is referenced inside a nested array literal (e.g. $b = [[&$a]]), assignments to the nested path ($b[0][0] = 2) now correctly propagate to the referenced variable ($a). The array reference setup in AssignHandler is refactored into a recursive method that builds chained ArrayDimFetch expressions for arbitrarily nested arrays. When an intermediate array path is reassigned ($b[0] = []), the nested intertwined refs are invalidated by checking whether the dim-fetch chain still resolves to valid offsets, preventing stale references from propagating incorrect types. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignHandler.php | 120 ++++++++++++--------- src/Analyser/MutatingScope.php | 46 +++++++- tests/PHPStan/Analyser/nsrt/bug-14333.php | 6 +- 3 files changed, 119 insertions(+), 53 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 37ccab986d..f4ea4deba3 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -318,55 +318,7 @@ public function processAssignVar( } if ($assignedExpr instanceof Expr\Array_) { - $implicitIndex = 0; - foreach ($assignedExpr->items as $arrayItem) { - if ($arrayItem->key !== null && $implicitIndex !== null) { - $keyValues = $scope->getType($arrayItem->key)->getConstantScalarValues(); - if (count($keyValues) === 1) { - $keyValue = $keyValues[0]; - if (is_int($keyValue) && $keyValue >= $implicitIndex) { - $implicitIndex = $keyValue + 1; - } - } else { - $implicitIndex = null; - } - } - - if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { - if ($arrayItem->key === null && $implicitIndex !== null) { - $implicitIndex++; - } - continue; - } - - $refVarName = $arrayItem->value->name; - if ($arrayItem->key !== null) { - $dimExpr = $arrayItem->key; - } elseif ($implicitIndex !== null) { - $dimExpr = new Node\Scalar\Int_($implicitIndex); - $implicitIndex++; - } else { - continue; - } - - $dimFetchExpr = new ArrayDimFetch(new Variable($var->name), $dimExpr); - $refType = $scope->getType(new Variable($refVarName)); - $refNativeType = $scope->getNativeType(new Variable($refVarName)); - - // When $varName's array key changes, update $refVarName - $scope = $scope->assignExpression( - new IntertwinedVariableByReferenceWithExpr($var->name, new Variable($refVarName), $dimFetchExpr), - $refType, - $refNativeType, - ); - - // When $refVarName changes, update $varName's array key - $scope = $scope->assignExpression( - new IntertwinedVariableByReferenceWithExpr($refVarName, $dimFetchExpr, new Variable($refVarName)), - $refType, - $refNativeType, - ); - } + $scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name)); } } else { $nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); @@ -989,6 +941,76 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr return $scope->hasVariableType($varNode->name)->negate(); } + private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope + { + $implicitIndex = 0; + foreach ($arrayExpr->items as $arrayItem) { + if ($arrayItem->key !== null && $implicitIndex !== null) { + $keyValues = $scope->getType($arrayItem->key)->getConstantScalarValues(); + if (count($keyValues) === 1) { + $keyValue = $keyValues[0]; + if (is_int($keyValue) && $keyValue >= $implicitIndex) { + $implicitIndex = $keyValue + 1; + } + } else { + $implicitIndex = null; + } + } + + if ($arrayItem->key !== null) { + $dimExpr = $arrayItem->key; + } elseif ($implicitIndex !== null) { + $dimExpr = new Node\Scalar\Int_($implicitIndex); + } else { + $dimExpr = null; + } + + if ($arrayItem->value instanceof Expr\Array_ && $dimExpr !== null) { + $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); + $scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr); + } + + if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { + if ($arrayItem->key === null && $implicitIndex !== null) { + $implicitIndex++; + } + continue; + } + + if ($dimExpr === null) { + if ($arrayItem->key === null && $implicitIndex !== null) { + $implicitIndex++; + } + continue; + } + + $refVarName = $arrayItem->value->name; + if ($arrayItem->key === null && $implicitIndex !== null) { + $implicitIndex++; + } + + $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); + $refType = $scope->getType(new Variable($refVarName)); + $refNativeType = $scope->getNativeType(new Variable($refVarName)); + + // When $rootVarName's array key changes, update $refVarName + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($rootVarName, new Variable($refVarName), $dimFetchExpr), + $refType, + $refNativeType, + ); + + // When $refVarName changes, update $rootVarName's array key + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($refVarName, $dimFetchExpr, new Variable($refVarName)), + $refType, + $refNativeType, + ); + } + + return $scope; + } + /** * @param list $dimFetchStack * @param list $offsetTypes diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 11309ef418..16e427d90d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2605,7 +2605,8 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } - foreach ($scope->expressionTypes as $expressionType) { + $invalidatedIntertwinedRefs = []; + foreach ($scope->expressionTypes as $exprString => $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { continue; } @@ -2616,6 +2617,12 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp continue; } + $assignedExpr = $expressionType->getExpr()->getAssignedExpr(); + if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->var instanceof Expr\ArrayDimFetch && !$this->isNestedDimFetchPathValid($scope, $assignedExpr)) { + $invalidatedIntertwinedRefs[] = $exprString; + continue; + } + $has = $scope->hasExpressionType($expressionType->getExpr()->getExpr()); if ( $expressionType->getExpr()->getExpr() instanceof Variable @@ -2643,12 +2650,28 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } + foreach ($invalidatedIntertwinedRefs as $exprString) { + unset($scope->expressionTypes[$exprString]); + unset($scope->nativeExpressionTypes[$exprString]); + unset($preservedIntertwinedRefs[$exprString]); + unset($preservedNativeIntertwinedRefs[$exprString]); + } + // Re-add intertwined refs that were lost during propagation foreach ($preservedIntertwinedRefs as $exprString => $exprTypeHolder) { if (array_key_exists($exprString, $scope->expressionTypes)) { continue; } + $intertwinedExpr = $exprTypeHolder->getExpr(); + if ($intertwinedExpr instanceof IntertwinedVariableByReferenceWithExpr) { + $assignedExpr = $intertwinedExpr->getAssignedExpr(); + if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->var instanceof Expr\ArrayDimFetch && !$this->isNestedDimFetchPathValid($scope, $assignedExpr)) { + unset($preservedNativeIntertwinedRefs[$exprString]); + continue; + } + } + $scope->expressionTypes[$exprString] = $exprTypeHolder; } foreach ($preservedNativeIntertwinedRefs as $exprString => $exprTypeHolder) { @@ -2662,6 +2685,27 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp return $scope; } + private function isNestedDimFetchPathValid(self $scope, Expr\ArrayDimFetch $dimFetch): bool + { + // Check that each intermediate ArrayDimFetch in the chain has the expected offset + if ($dimFetch->dim === null) { + return false; + } + + $varType = $scope->getType($dimFetch->var); + $dimType = $scope->getType($dimFetch->dim); + + if (!$varType->hasOffsetValueType($dimType)->yes()) { + return false; + } + + if ($dimFetch->var instanceof Expr\ArrayDimFetch) { + return $this->isNestedDimFetchPathValid($scope, $dimFetch->var); + } + + return true; + } + private function unsetExpression(Expr $expr): self { $scope = $this; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index 2ea94640e1..7526fb43ce 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -57,13 +57,13 @@ function testNested(): void $b[0][0] = 2; - assertType('1', $a); // Should be 2 in real PHP, but nested array reference tracking is not implemented + assertType('2', $a); $b[0] = []; - assertType('1', $a); + assertType('2', $a); $b[0][0] = 3; - assertType('1', $a); + assertType('2', $a); } From 5aee21aad0074778eea94c91666514ef64e7859f Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 20 Mar 2026 13:17:01 +0100 Subject: [PATCH 07/15] Simplify --- src/Analyser/ExprHandler/AssignHandler.php | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index f4ea4deba3..1fe5d018a2 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -961,34 +961,21 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam $dimExpr = $arrayItem->key; } elseif ($implicitIndex !== null) { $dimExpr = new Node\Scalar\Int_($implicitIndex); + $implicitIndex++; } else { - $dimExpr = null; + continue; } - if ($arrayItem->value instanceof Expr\Array_ && $dimExpr !== null) { + if ($arrayItem->value instanceof Expr\Array_) { $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); $scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr); } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { - if ($arrayItem->key === null && $implicitIndex !== null) { - $implicitIndex++; - } - continue; - } - - if ($dimExpr === null) { - if ($arrayItem->key === null && $implicitIndex !== null) { - $implicitIndex++; - } continue; } $refVarName = $arrayItem->value->name; - if ($arrayItem->key === null && $implicitIndex !== null) { - $implicitIndex++; - } - $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); $refType = $scope->getType(new Variable($refVarName)); $refNativeType = $scope->getNativeType(new Variable($refVarName)); From f6a987e15fba8f076590281ceaafdb275efeff3f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 12:22:33 +0000 Subject: [PATCH 08/15] Add comment explaining why implicitIndex is set to null for non-constant keys Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignHandler.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1fe5d018a2..a5bfc624a2 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -953,6 +953,8 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam $implicitIndex = $keyValue + 1; } } else { + // Non-constant key makes subsequent implicit indices unpredictable, + // so we stop tracking implicit indices for the rest of the array $implicitIndex = null; } } From f1f6eb72bc9ce2aa697559f526b1772e4af49974 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 12:39:08 +0000 Subject: [PATCH 09/15] Move intertwined ref preservation from assignVariable to invalidateExpression Instead of preserving and restoring non-variable-to-variable intertwined refs in assignVariable, handle them in invalidateExpression by skipping refs whose endpoint variables (variableName, root of expr, root of assignedExpr) match the variable being invalidated. Refs are still invalidated when a non-endpoint variable (e.g. a dim index like $k in $a[$k]) changes. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 69 +++++-------------- ...IntertwinedVariableByReferenceWithExpr.php | 10 --- 2 files changed, 18 insertions(+), 61 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 16e427d90d..964e3e284a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2573,29 +2573,6 @@ public function isUndefinedExpressionAllowed(Expr $expr): bool public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, array $intertwinedPropagatedFrom = []): self { $node = new Variable($variableName); - - // Collect non-variable-to-variable intertwined refs for this variable before invalidation, - // as they may be lost during assignExpression and recursive propagation - $preservedIntertwinedRefs = []; - $preservedNativeIntertwinedRefs = []; - foreach ($this->expressionTypes as $exprString => $exprTypeHolder) { - $exprExpr = $exprTypeHolder->getExpr(); - if ( - !($exprExpr instanceof IntertwinedVariableByReferenceWithExpr) - || $exprExpr->getVariableName() !== $variableName - || $exprExpr->isVariableToVariableReference() - ) { - continue; - } - - $preservedIntertwinedRefs[$exprString] = $exprTypeHolder; - if (!array_key_exists($exprString, $this->nativeExpressionTypes)) { - continue; - } - - $preservedNativeIntertwinedRefs[$exprString] = $this->nativeExpressionTypes[$exprString]; - } - $scope = $this->assignExpression($node, $type, $nativeType); if ($certainty->no()) { throw new ShouldNotHappenException(); @@ -2653,33 +2630,6 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp foreach ($invalidatedIntertwinedRefs as $exprString) { unset($scope->expressionTypes[$exprString]); unset($scope->nativeExpressionTypes[$exprString]); - unset($preservedIntertwinedRefs[$exprString]); - unset($preservedNativeIntertwinedRefs[$exprString]); - } - - // Re-add intertwined refs that were lost during propagation - foreach ($preservedIntertwinedRefs as $exprString => $exprTypeHolder) { - if (array_key_exists($exprString, $scope->expressionTypes)) { - continue; - } - - $intertwinedExpr = $exprTypeHolder->getExpr(); - if ($intertwinedExpr instanceof IntertwinedVariableByReferenceWithExpr) { - $assignedExpr = $intertwinedExpr->getAssignedExpr(); - if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->var instanceof Expr\ArrayDimFetch && !$this->isNestedDimFetchPathValid($scope, $assignedExpr)) { - unset($preservedNativeIntertwinedRefs[$exprString]); - continue; - } - } - - $scope->expressionTypes[$exprString] = $exprTypeHolder; - } - foreach ($preservedNativeIntertwinedRefs as $exprString => $exprTypeHolder) { - if (array_key_exists($exprString, $scope->nativeExpressionTypes)) { - continue; - } - - $scope->nativeExpressionTypes[$exprString] = $exprTypeHolder; } return $scope; @@ -2918,7 +2868,13 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require $exprExpr = $exprTypeHolder->getExpr(); if ( $exprExpr instanceof IntertwinedVariableByReferenceWithExpr - && $exprExpr->isVariableToVariableReference() + && $expressionToInvalidate instanceof Variable + && is_string($expressionToInvalidate->name) + && ( + $exprExpr->getVariableName() === $expressionToInvalidate->name + || $this->getIntertwinedRefRootVariableName($exprExpr->getExpr()) === $expressionToInvalidate->name + || $this->getIntertwinedRefRootVariableName($exprExpr->getAssignedExpr()) === $expressionToInvalidate->name + ) ) { continue; } @@ -2989,6 +2945,17 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require ); } + private function getIntertwinedRefRootVariableName(Expr $expr): ?string + { + if ($expr instanceof Variable && is_string($expr->name)) { + return $expr->name; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->getIntertwinedRefRootVariableName($expr->var); + } + return null; + } + private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool { if ($requireMoreCharacters && $exprStringToInvalidate === $exprString) { diff --git a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php index d23d7e4761..2b4358a4a6 100644 --- a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php +++ b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php @@ -4,9 +4,7 @@ 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,14 +29,6 @@ public function getAssignedExpr(): Expr return $this->assignedExpr; } - public function isVariableToVariableReference(): bool - { - return $this->expr instanceof Variable - && is_string($this->expr->name) - && $this->assignedExpr instanceof Variable - && is_string($this->assignedExpr->name); - } - #[Override] public function getType(): string { From 88ea88929c11eeadc5e6e8b1644e8d8b556618d6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 13:09:22 +0000 Subject: [PATCH 10/15] Move intertwined ref preservation condition into shouldInvalidateExpression Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 964e3e284a..227bb0ff64 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2866,18 +2866,6 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require foreach ($expressionTypes as $exprString => $exprTypeHolder) { $exprExpr = $exprTypeHolder->getExpr(); - if ( - $exprExpr instanceof IntertwinedVariableByReferenceWithExpr - && $expressionToInvalidate instanceof Variable - && is_string($expressionToInvalidate->name) - && ( - $exprExpr->getVariableName() === $expressionToInvalidate->name - || $this->getIntertwinedRefRootVariableName($exprExpr->getExpr()) === $expressionToInvalidate->name - || $this->getIntertwinedRefRootVariableName($exprExpr->getAssignedExpr()) === $expressionToInvalidate->name - ) - ) { - continue; - } if (!$this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $exprString, $requireMoreCharacters, $invalidatingClass)) { continue; } @@ -2958,6 +2946,19 @@ private function getIntertwinedRefRootVariableName(Expr $expr): ?string private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool { + if ( + $expr instanceof IntertwinedVariableByReferenceWithExpr + && $exprToInvalidate instanceof Variable + && is_string($exprToInvalidate->name) + && ( + $expr->getVariableName() === $exprToInvalidate->name + || $this->getIntertwinedRefRootVariableName($expr->getExpr()) === $exprToInvalidate->name + || $this->getIntertwinedRefRootVariableName($expr->getAssignedExpr()) === $exprToInvalidate->name + ) + ) { + return false; + } + if ($requireMoreCharacters && $exprStringToInvalidate === $exprString) { return false; } From 3200c9f5ed14e7638bdd668a8396a2bee45ebe20 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 20 Mar 2026 14:23:12 +0100 Subject: [PATCH 11/15] Simplify --- src/Analyser/MutatingScope.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 227bb0ff64..978e3e9af8 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2595,8 +2595,13 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } $assignedExpr = $expressionType->getExpr()->getAssignedExpr(); - if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->var instanceof Expr\ArrayDimFetch && !$this->isNestedDimFetchPathValid($scope, $assignedExpr)) { - $invalidatedIntertwinedRefs[] = $exprString; + if ( + $assignedExpr instanceof Expr\ArrayDimFetch + && $assignedExpr->var instanceof Expr\ArrayDimFetch + && !$this->isNestedDimFetchPathValid($scope, $assignedExpr) + ) { + unset($scope->expressionTypes[$exprString]); + unset($scope->nativeExpressionTypes[$exprString]); continue; } @@ -2624,12 +2629,6 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), ); } - - } - - foreach ($invalidatedIntertwinedRefs as $exprString) { - unset($scope->expressionTypes[$exprString]); - unset($scope->nativeExpressionTypes[$exprString]); } return $scope; From c5824eb526b991e6c89ff42535624df5c1a2e921 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 13:29:34 +0000 Subject: [PATCH 12/15] Rename and simplify isDimFetchPathReachable to absorb nested check Move the `$assignedExpr->var instanceof ArrayDimFetch` guard into the method itself and rename from isNestedDimFetchPathValid to isDimFetchPathReachable for clarity. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 978e3e9af8..3472c99e26 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2597,8 +2597,7 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $assignedExpr = $expressionType->getExpr()->getAssignedExpr(); if ( $assignedExpr instanceof Expr\ArrayDimFetch - && $assignedExpr->var instanceof Expr\ArrayDimFetch - && !$this->isNestedDimFetchPathValid($scope, $assignedExpr) + && !$this->isDimFetchPathReachable($scope, $assignedExpr) ) { unset($scope->expressionTypes[$exprString]); unset($scope->nativeExpressionTypes[$exprString]); @@ -2634,13 +2633,16 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp return $scope; } - private function isNestedDimFetchPathValid(self $scope, Expr\ArrayDimFetch $dimFetch): bool + private function isDimFetchPathReachable(self $scope, Expr\ArrayDimFetch $dimFetch): bool { - // Check that each intermediate ArrayDimFetch in the chain has the expected offset if ($dimFetch->dim === null) { return false; } + if (!$dimFetch->var instanceof Expr\ArrayDimFetch) { + return true; + } + $varType = $scope->getType($dimFetch->var); $dimType = $scope->getType($dimFetch->dim); @@ -2648,11 +2650,7 @@ private function isNestedDimFetchPathValid(self $scope, Expr\ArrayDimFetch $dimF return false; } - if ($dimFetch->var instanceof Expr\ArrayDimFetch) { - return $this->isNestedDimFetchPathValid($scope, $dimFetch->var); - } - - return true; + return $this->isDimFetchPathReachable($scope, $dimFetch->var); } private function unsetExpression(Expr $expr): self From 85b5b5f98b79e43f35d901f97c097a32d7c57f97 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 20 Mar 2026 14:55:40 +0100 Subject: [PATCH 13/15] Add failing test --- tests/PHPStan/Analyser/nsrt/bug-14333.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index 7526fb43ce..d0012fcc6d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -67,3 +67,16 @@ function testNested(): void assertType('2', $a); } + +function foo(array &$a): void {} + +function testFunctionCall() { + $b = 1; + + $c = [&$b]; + assertType('array{1}', $c); + + foo($c); + assertType('array', $c); + assertType('mixed', $b); +} From 94a3bd9c03d33809ed2122210ff9e4d9752d7865 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 20 Mar 2026 15:12:55 +0100 Subject: [PATCH 14/15] Fix lint --- src/Analyser/MutatingScope.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 3472c99e26..1466510375 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2582,7 +2582,6 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } - $invalidatedIntertwinedRefs = []; foreach ($scope->expressionTypes as $exprString => $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { continue; From f0ba82abf4091217a76df9e4e790b0c3680294b7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 20 Mar 2026 14:25:13 +0000 Subject: [PATCH 15/15] Prevent circular back-propagation of intertwined refs during variable reassignment When a variable like $c is completely reassigned (e.g. by a by-ref function call), the propagation chain would update referenced variables correctly ($b = mixed), but then the reverse ref would assign back to $c[0], making $c appear as non-empty-array&hasOffsetValue(0, mixed) instead of array. Skip the reverse assignment when the target's root variable is already in the propagation chain. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1466510375..4ce247488f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2621,6 +2621,10 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp array_merge($intertwinedPropagatedFrom, [$variableName]), ); } else { + $targetRootVar = $this->getIntertwinedRefRootVariableName($expressionType->getExpr()->getExpr()); + if ($targetRootVar !== null && in_array($targetRootVar, $intertwinedPropagatedFrom, true)) { + continue; + } $scope = $scope->assignExpression( $expressionType->getExpr()->getExpr(), $scope->getType($expressionType->getExpr()->getAssignedExpr()),