From 45d960ebad0fa08c36cdcea0752ac5c85997bc4e Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:10:56 +0000 Subject: [PATCH] Fix phpstan/phpstan#14336: Degrade list to array when assigning with arbitrary int offset When assigning to a list-typed array variable with an arbitrary `int` offset (e.g., `$list[$int] = $value`), PHPStan incorrectly preserved the `list` type. Lists require sequential 0-based integer keys, so assigning with an arbitrary `int` (which may include negative values) should degrade the type to `array`. Two changes fix the issue: 1. In AssignHandler::produceArrayDimFetchAssignValueToWrite, prevent setExistingOffsetValueType from being used for lists when the offset type is not guaranteed to be non-negative (int<0, max>). 2. In IntersectionType::setOffsetValueType, only re-add AccessoryArrayListType for list-of-arrays when the offset type is null (append) or non-negative. --- src/Analyser/ExprHandler/AssignHandler.php | 1 + src/Type/IntersectionType.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-10089.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13629.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14245.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14336.php | 207 +++++++++++++++++++++ 6 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14336.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index df779e633e..fcba95fbac 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -992,6 +992,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar && $arrayDimFetch !== null && $scope->hasExpressionType($arrayDimFetch)->yes() && !$offsetValueType->hasOffsetValueType($offsetType)->no() + && (!$offsetValueType->isList()->yes() || IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($offsetType)->yes()) ) { $hasOffsetType = null; if ($offsetType instanceof ConstantStringType || $offsetType instanceof ConstantIntegerType) { diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 2e5514dc02..bd8cd20d9b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -981,7 +981,9 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni } } - if ($this->isList()->yes() && $this->getIterableValueType()->isArray()->yes()) { + if ($this->isList()->yes() && $this->getIterableValueType()->isArray()->yes() + && ($offsetType === null || IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($offsetType)->yes()) + ) { $result = TypeCombinator::intersect($result, new AccessoryArrayListType()); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10089.php b/tests/PHPStan/Analyser/nsrt/bug-10089.php index 21122cdfc3..ff8d55eeed 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10089.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10089.php @@ -22,7 +22,7 @@ protected function create_matrix(int $size): array $matrix[$size - 1][8] = 3; // non-empty-array&hasOffsetValue(8, 3)> - assertType('non-empty-list, 0|3>>', $matrix); + assertType('non-empty-array, 0|3>>', $matrix); for ($i = 0; $i <= $size; $i++) { if ($matrix[$i][8] === 0) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-13629.php b/tests/PHPStan/Analyser/nsrt/bug-13629.php index 621b4169bb..ee2724f4a1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13629.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13629.php @@ -31,7 +31,7 @@ function test(array $xsdFiles, array $groupedByNamespace, array $extraNamespaces } } // After assigning with string keys ($viewHelper['name']), $xsdFiles[$xmlNamespace] should NOT be a list - assertType('array, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]); + assertType('array|string, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]); $xsdFiles[$xmlNamespace] = array_values($xsdFiles[$xmlNamespace]); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14245.php b/tests/PHPStan/Analyser/nsrt/bug-14245.php index 63333b5de2..a7855fc6e0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14245.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14245.php @@ -144,5 +144,5 @@ function ArrayKeyExistsKeepsList($needle): void { if (array_key_exists($needle, $list)) { $list[$needle] = 37; } - assertType('list', $list); + assertType('array', $list); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14336.php b/tests/PHPStan/Analyser/nsrt/bug-14336.php new file mode 100644 index 0000000000..e606ca92d5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14336.php @@ -0,0 +1,207 @@ + $list + */ +function test(array $list, int $int): void { + $list[$int] = 'foo'; + assertType('non-empty-array', $list); +} + +/** + * @param array> $xsdFiles + * @param array> $groupedByNamespace + * @param array> $extraNamespaces + */ +function test2(array $xsdFiles, array $groupedByNamespace, array $extraNamespaces, int $int): void { + foreach ($extraNamespaces as $mergedNamespace) { + if (count($mergedNamespace) < 2) { + continue; + } + + $targetNamespace = end($mergedNamespace); + if (!isset($groupedByNamespace[$targetNamespace])) { + continue; + } + $xmlNamespace = $groupedByNamespace[$targetNamespace][0]['xmlNamespace']; + + $xsdFiles[$xmlNamespace] = []; + assertType('array{}', $xsdFiles[$xmlNamespace]); + foreach ($mergedNamespace as $namespace) { + foreach ($groupedByNamespace[$namespace] ?? [] as $viewHelper) { + assertType('array{xmlNamespace: string, namespace: string, name: string}', $viewHelper); + $xsdFiles[$xmlNamespace][$int] = $viewHelper; + assertType('non-empty-array', $xsdFiles[$xmlNamespace]); + } + assertType('array', $xsdFiles[$xmlNamespace]); + } + assertType('array', $xsdFiles[$xmlNamespace]); + } +} + +/** + * @param list $list + */ +function testInLoop(array $list, int $int): void { + foreach ([1, 2, 3] as $item) { + $list[$int] = 'foo'; + } + assertType('non-empty-array', $list); +} + +/** + * @param array> $map + */ +function testNestedDimFetchInLoop(array $map, string $key, int $int): void { + $map[$key] = []; + foreach ([1, 2, 3] as $item) { + $map[$key][$int] = 'foo'; + } + assertType('non-empty-array', $map[$key]); +} + +/** + * @param array> $map + * @param list $items + * @param list $items2 + */ +function testDoubleNestedForeachDimFetch(array $map, string $key, int $int, array $items, array $items2): void { + $map[$key] = []; + foreach ($items as $item) { + foreach ($items2 as $item2) { + $map[$key][$int] = $item2; + } + } + assertType('array', $map[$key]); +} + +/** + * @param array> $map + * @param list $items + */ +function testSingleVariableForeach(array $map, string $key, int $int, array $items): void { + $map[$key] = []; + foreach ($items as $item) { + $map[$key][$int] = $item; + } + assertType('array', $map[$key]); +} + +/** + * @param array> $map + * @param list $items + * @param list $outerItems + */ +function testOuterForeach(array $map, string $key, int $int, array $items, array $outerItems): void { + foreach ($outerItems as $outerItem) { + $map[$key] = []; + foreach ($items as $item) { + $map[$key][$int] = $item; + } + assertType('array', $map[$key]); + } +} + +/** + * @param array> $map + * @param list $items + * @param list $outerItems + */ +function testOuterForeachWithContinue(array $map, string $key, int $int, array $items, array $outerItems): void { + foreach ($outerItems as $outerItem) { + if (strlen($outerItem) < 2) { + continue; + } + $map[$key] = []; + foreach ($items as $item) { + $map[$key][$int] = $item; + } + assertType('array', $map[$key]); + } +} + +/** + * @param array> $map + * @param list> $nestedItems + * @param list $outerItems + */ +function testNestedInnerForeach(array $map, string $key, int $int, array $nestedItems, array $outerItems): void { + foreach ($outerItems as $outerItem) { + if (strlen($outerItem) < 2) { + continue; + } + $map[$key] = []; + foreach ($nestedItems as $items) { + foreach ($items as $item) { + $map[$key][$int] = $item; + } + } + assertType('array', $map[$key]); + } +} + +/** + * @param array> $map + * @param array> $nestedItems + * @param list $outerItems + */ +function testNestedInnerForeachNullCoalesce(array $map, string $key, int $int, array $nestedItems, array $outerItems): void { + foreach ($outerItems as $outerItem) { + if (strlen($outerItem) < 2) { + continue; + } + $map[$key] = []; + foreach ($outerItems as $ns) { + foreach ($nestedItems[$ns] ?? [] as $item) { + $map[$key][$int] = $item; + } + } + assertType('array', $map[$key]); + } +} + +/** + * @param array> $map + * @param array> $grouped + * @param array> $extra + */ +function testCloseToOriginal(array $map, array $grouped, array $extra, int $int): void { + foreach ($extra as $merged) { + if (count($merged) < 2) { + continue; + } + $target = end($merged); + if (!isset($grouped[$target])) { + continue; + } + $key = $grouped[$target][0]['ns']; + + $map[$key] = []; + foreach ($merged as $ns) { + foreach ($grouped[$ns] ?? [] as $item) { + $map[$key][$int] = $item; + } + } + assertType('array', $map[$key]); + } +} + +/** + * @param list $list + */ +function testAppend(array $list): void { + $list[] = 'foo'; + assertType('non-empty-list', $list); +} + +/** + * @param list $list + */ +function testLiteralZero(array $list): void { + $list[0] = 'foo'; + assertType("non-empty-list&hasOffsetValue(0, 'foo')", $list); +}