From b45c51d54a9a1fc2266f8623f29b9900f64f227c Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sat, 9 May 2026 16:10:05 +0000 Subject: [PATCH] Preserve array type through dependent offset assignment with template keys When assigning `$arr[$key] = $val` where `$key` is a template key-of and `$val` is T[$key] (an OffsetAccessType), the assignment is type- preserving by definition. Previously PHPStan would eagerly resolve the OffsetAccessType, losing the dependent relationship between key and value, then widen all value types to the union of all possible values. Detect this pattern in AssignHandler by examining the raw (unresolved) expression type: if the assigned value's raw type is an OffsetAccessType whose offset matches the dim's template parameter and whose accessed type is a supertype of the array being assigned to, skip the widening step and preserve the original array type. Also compare template parameters by identity (name + scope) rather than strict equals, since the same template K may appear as TemplateKeyOfType in the OffsetAccessType but as TemplateStringType in the resolved dim. Closes https://github.com/phpstan/phpstan/issues/7380 --- src/Analyser/ExprHandler/AssignHandler.php | 49 +++++++++++++- src/Type/OffsetAccessType.php | 10 +++ .../Analyser/AnalyserIntegrationTest.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-7380.php | 64 +++++++++++++++++++ 4 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7380.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 5d2a084ec04..0544ce76c6f 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -58,15 +58,18 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\ErrorType; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; +use PHPStan\Type\OffsetAccessType; use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use TypeError; +use function array_key_exists; use function array_key_last; use function array_merge; use function array_pop; @@ -439,7 +442,28 @@ public function processAssignVar( $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; - [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); + $dependentPreserved = false; + if ( + count($dimFetchStack) === 1 + && count($offsetTypes) === 1 + && $offsetTypes[0][0] !== null + && TypeUtils::containsTemplateType($offsetTypes[0][0]) + ) { + $rawValueType = $this->getRawExpressionType($scope, $assignedExpr); + if ( + $rawValueType instanceof OffsetAccessType + && $this->isSameTemplateOffset($rawValueType->getAccessedOffset(), $offsetTypes[0][0]) + && $rawValueType->getAccessedType()->isSuperTypeOf($offsetValueType)->yes() + ) { + $dependentPreserved = true; + $additionalExpressions = [[$dimFetchStack[0], $valueToWrite]]; + $valueToWrite = $offsetValueType; + } + } + + if (!$dependentPreserved) { + [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); + } if (!$offsetValueType->equals($offsetNativeValueType) || !$valueToWrite->equals($nativeValueToWrite)) { [$nativeValueToWrite, $additionalNativeExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); @@ -1258,4 +1282,27 @@ private function isSameVariable(Expr $a, Expr $b): bool return false; } + private function isSameTemplateOffset(Type $a, Type $b): bool + { + if ($a->equals($b)) { + return true; + } + + if ($a instanceof TemplateType && $b instanceof TemplateType) { + return $a->getScope()->equals($b->getScope()) && $a->getName() === $b->getName(); + } + + return false; + } + + private function getRawExpressionType(MutatingScope $scope, Expr $expr): ?Type + { + $key = $scope->getNodeKey($expr); + if (!array_key_exists($key, $scope->expressionTypes)) { + return null; + } + + return $scope->expressionTypes[$key]->getType(); + } + } diff --git a/src/Type/OffsetAccessType.php b/src/Type/OffsetAccessType.php index 5e4ef1aec39..8b0c011dbb7 100644 --- a/src/Type/OffsetAccessType.php +++ b/src/Type/OffsetAccessType.php @@ -24,6 +24,16 @@ public function __construct( { } + public function getAccessedType(): Type + { + return $this->type; + } + + public function getAccessedOffset(): Type + { + return $this->offset; + } + public function getReferencedClasses(): array { return array_merge( diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 803d37fe8c9..2cea597e93c 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -824,9 +824,8 @@ public function testBug7215(): void public function testBug7094(): void { - // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-7094.php'); - $this->assertCount(6, $errors); + $this->assertCount(5, $errors); $this->assertSame('Parameter #2 $val of method Bug7094\Foo::setAttribute() contains unresolvable type.', $errors[0]->getMessage()); $this->assertSame(74, $errors[0]->getLine()); @@ -838,9 +837,6 @@ public function testBug7094(): void $this->assertSame(78, $errors[3]->getLine()); $this->assertSame('Return type of call to method Bug7094\Foo::getAttribute() contains unresolvable type.', $errors[4]->getMessage()); $this->assertSame(79, $errors[4]->getLine()); - - $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, non-empty-array<\'bar\'|\'baz\'|\'foo\'|K of string, 5|6|7|bool|string> given.', $errors[5]->getMessage()); - $this->assertSame(29, $errors[5]->getLine()); } #[RequiresPhp('>= 8.0.0')] diff --git a/tests/PHPStan/Analyser/nsrt/bug-7380.php b/tests/PHPStan/Analyser/nsrt/bug-7380.php new file mode 100644 index 00000000000..31a8a84c487 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7380.php @@ -0,0 +1,64 @@ + + * @param K $key + * @param Attrs[K] $val + */ + public function setAttribute(string $key, mixed $val): void + { + $attr = $this->getAttributes(); + $attr[$key] = $val; + assertType('array{foo?: string, bar?: 5|6|7, baz?: bool}', $attr); + $this->setAttributes($attr); + } + + /** @return Attrs */ + public function getAttributes(): array + { + return []; + } + + /** @param Attrs $attr */ + public function setAttributes(array $attr): void + { + } +} + +/** + * @template T of array + */ +final class GenericBar { + + /** + * @template K of key-of + * @param K $key + * @param T[K] $val + */ + public function setAttribute(string $key, mixed $val): void + { + $attr = $this->getAttributes(); + $attr[$key] = $val; + $this->setAttributes($attr); + } + + /** @return T */ + public function getAttributes(): array + { + return []; + } + + /** @param T $attr */ + public function setAttributes(array $attr): void + { + } +}