Skip to content

Commit 2407895

Browse files
phpstan-botclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 9ba6411 commit 2407895

3 files changed

Lines changed: 119 additions & 53 deletions

File tree

src/Analyser/ExprHandler/AssignHandler.php

Lines changed: 71 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -318,55 +318,7 @@ public function processAssignVar(
318318
}
319319

320320
if ($assignedExpr instanceof Expr\Array_) {
321-
$implicitIndex = 0;
322-
foreach ($assignedExpr->items as $arrayItem) {
323-
if ($arrayItem->key !== null && $implicitIndex !== null) {
324-
$keyValues = $scope->getType($arrayItem->key)->getConstantScalarValues();
325-
if (count($keyValues) === 1) {
326-
$keyValue = $keyValues[0];
327-
if (is_int($keyValue) && $keyValue >= $implicitIndex) {
328-
$implicitIndex = $keyValue + 1;
329-
}
330-
} else {
331-
$implicitIndex = null;
332-
}
333-
}
334-
335-
if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) {
336-
if ($arrayItem->key === null && $implicitIndex !== null) {
337-
$implicitIndex++;
338-
}
339-
continue;
340-
}
341-
342-
$refVarName = $arrayItem->value->name;
343-
if ($arrayItem->key !== null) {
344-
$dimExpr = $arrayItem->key;
345-
} elseif ($implicitIndex !== null) {
346-
$dimExpr = new Node\Scalar\Int_($implicitIndex);
347-
$implicitIndex++;
348-
} else {
349-
continue;
350-
}
351-
352-
$dimFetchExpr = new ArrayDimFetch(new Variable($var->name), $dimExpr);
353-
$refType = $scope->getType(new Variable($refVarName));
354-
$refNativeType = $scope->getNativeType(new Variable($refVarName));
355-
356-
// When $varName's array key changes, update $refVarName
357-
$scope = $scope->assignExpression(
358-
new IntertwinedVariableByReferenceWithExpr($var->name, new Variable($refVarName), $dimFetchExpr),
359-
$refType,
360-
$refNativeType,
361-
);
362-
363-
// When $refVarName changes, update $varName's array key
364-
$scope = $scope->assignExpression(
365-
new IntertwinedVariableByReferenceWithExpr($refVarName, $dimFetchExpr, new Variable($refVarName)),
366-
$refType,
367-
$refNativeType,
368-
);
369-
}
321+
$scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name));
370322
}
371323
} else {
372324
$nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context);
@@ -989,6 +941,76 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr
989941
return $scope->hasVariableType($varNode->name)->negate();
990942
}
991943

944+
private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope
945+
{
946+
$implicitIndex = 0;
947+
foreach ($arrayExpr->items as $arrayItem) {
948+
if ($arrayItem->key !== null && $implicitIndex !== null) {
949+
$keyValues = $scope->getType($arrayItem->key)->getConstantScalarValues();
950+
if (count($keyValues) === 1) {
951+
$keyValue = $keyValues[0];
952+
if (is_int($keyValue) && $keyValue >= $implicitIndex) {
953+
$implicitIndex = $keyValue + 1;
954+
}
955+
} else {
956+
$implicitIndex = null;
957+
}
958+
}
959+
960+
if ($arrayItem->key !== null) {
961+
$dimExpr = $arrayItem->key;
962+
} elseif ($implicitIndex !== null) {
963+
$dimExpr = new Node\Scalar\Int_($implicitIndex);
964+
} else {
965+
$dimExpr = null;
966+
}
967+
968+
if ($arrayItem->value instanceof Expr\Array_ && $dimExpr !== null) {
969+
$dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr);
970+
$scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr);
971+
}
972+
973+
if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) {
974+
if ($arrayItem->key === null && $implicitIndex !== null) {
975+
$implicitIndex++;
976+
}
977+
continue;
978+
}
979+
980+
if ($dimExpr === null) {
981+
if ($arrayItem->key === null && $implicitIndex !== null) {
982+
$implicitIndex++;
983+
}
984+
continue;
985+
}
986+
987+
$refVarName = $arrayItem->value->name;
988+
if ($arrayItem->key === null && $implicitIndex !== null) {
989+
$implicitIndex++;
990+
}
991+
992+
$dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr);
993+
$refType = $scope->getType(new Variable($refVarName));
994+
$refNativeType = $scope->getNativeType(new Variable($refVarName));
995+
996+
// When $rootVarName's array key changes, update $refVarName
997+
$scope = $scope->assignExpression(
998+
new IntertwinedVariableByReferenceWithExpr($rootVarName, new Variable($refVarName), $dimFetchExpr),
999+
$refType,
1000+
$refNativeType,
1001+
);
1002+
1003+
// When $refVarName changes, update $rootVarName's array key
1004+
$scope = $scope->assignExpression(
1005+
new IntertwinedVariableByReferenceWithExpr($refVarName, $dimFetchExpr, new Variable($refVarName)),
1006+
$refType,
1007+
$refNativeType,
1008+
);
1009+
}
1010+
1011+
return $scope;
1012+
}
1013+
9921014
/**
9931015
* @param list<ArrayDimFetch> $dimFetchStack
9941016
* @param list<array{Type|null, ArrayDimFetch}> $offsetTypes

src/Analyser/MutatingScope.php

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2605,7 +2605,8 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp
26052605
$scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty);
26062606
}
26072607

2608-
foreach ($scope->expressionTypes as $expressionType) {
2608+
$invalidatedIntertwinedRefs = [];
2609+
foreach ($scope->expressionTypes as $exprString => $expressionType) {
26092610
if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) {
26102611
continue;
26112612
}
@@ -2616,6 +2617,12 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp
26162617
continue;
26172618
}
26182619

2620+
$assignedExpr = $expressionType->getExpr()->getAssignedExpr();
2621+
if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->var instanceof Expr\ArrayDimFetch && !$this->isNestedDimFetchPathValid($scope, $assignedExpr)) {
2622+
$invalidatedIntertwinedRefs[] = $exprString;
2623+
continue;
2624+
}
2625+
26192626
$has = $scope->hasExpressionType($expressionType->getExpr()->getExpr());
26202627
if (
26212628
$expressionType->getExpr()->getExpr() instanceof Variable
@@ -2643,12 +2650,28 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp
26432650

26442651
}
26452652

2653+
foreach ($invalidatedIntertwinedRefs as $exprString) {
2654+
unset($scope->expressionTypes[$exprString]);
2655+
unset($scope->nativeExpressionTypes[$exprString]);
2656+
unset($preservedIntertwinedRefs[$exprString]);
2657+
unset($preservedNativeIntertwinedRefs[$exprString]);
2658+
}
2659+
26462660
// Re-add intertwined refs that were lost during propagation
26472661
foreach ($preservedIntertwinedRefs as $exprString => $exprTypeHolder) {
26482662
if (array_key_exists($exprString, $scope->expressionTypes)) {
26492663
continue;
26502664
}
26512665

2666+
$intertwinedExpr = $exprTypeHolder->getExpr();
2667+
if ($intertwinedExpr instanceof IntertwinedVariableByReferenceWithExpr) {
2668+
$assignedExpr = $intertwinedExpr->getAssignedExpr();
2669+
if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->var instanceof Expr\ArrayDimFetch && !$this->isNestedDimFetchPathValid($scope, $assignedExpr)) {
2670+
unset($preservedNativeIntertwinedRefs[$exprString]);
2671+
continue;
2672+
}
2673+
}
2674+
26522675
$scope->expressionTypes[$exprString] = $exprTypeHolder;
26532676
}
26542677
foreach ($preservedNativeIntertwinedRefs as $exprString => $exprTypeHolder) {
@@ -2662,6 +2685,27 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp
26622685
return $scope;
26632686
}
26642687

2688+
private function isNestedDimFetchPathValid(self $scope, Expr\ArrayDimFetch $dimFetch): bool
2689+
{
2690+
// Check that each intermediate ArrayDimFetch in the chain has the expected offset
2691+
if ($dimFetch->dim === null) {
2692+
return false;
2693+
}
2694+
2695+
$varType = $scope->getType($dimFetch->var);
2696+
$dimType = $scope->getType($dimFetch->dim);
2697+
2698+
if (!$varType->hasOffsetValueType($dimType)->yes()) {
2699+
return false;
2700+
}
2701+
2702+
if ($dimFetch->var instanceof Expr\ArrayDimFetch) {
2703+
return $this->isNestedDimFetchPathValid($scope, $dimFetch->var);
2704+
}
2705+
2706+
return true;
2707+
}
2708+
26652709
private function unsetExpression(Expr $expr): self
26662710
{
26672711
$scope = $this;

tests/PHPStan/Analyser/nsrt/bug-14333.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ function testNested(): void
5757

5858
$b[0][0] = 2;
5959

60-
assertType('1', $a); // Should be 2 in real PHP, but nested array reference tracking is not implemented
60+
assertType('2', $a);
6161

6262
$b[0] = [];
6363

64-
assertType('1', $a);
64+
assertType('2', $a);
6565

6666
$b[0][0] = 3;
6767

68-
assertType('1', $a);
68+
assertType('2', $a);
6969
}

0 commit comments

Comments
 (0)