From e2022cad407d7470f0606c0326db68e699988a2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:22:14 +0000 Subject: [PATCH 1/2] Restrict list type preservation in `IntersectionType::setOffsetValueType` to offsets within list key range - In `IntersectionType::setOffsetValueType`, change the condition for re-adding `AccessoryArrayListType` from checking if the offset is any integer (`$offsetType->toArrayKey()->isInteger()->yes()`) to checking if the offset is within the list's key type range (`$this->getIterableKeyType()->isSuperTypeOf($offsetType)->yes()`). - This prevents list type from being incorrectly preserved when assigning with an arbitrary `int` key (which includes negative values and could create gaps), while still preserving list type for `int<0, max>` offsets (valid list key range) used in nested modifications like `$list[$k]['key'] = value`. - Update bug-10089 test assertion: `$matrix[$size - 1][8] = 3` where `$size` is `int` correctly degrades the list since `int` is not within the `int<0, max>` key range. --- src/Type/IntersectionType.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-10089.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14336.php | 108 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14336.php diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 10482dae047..b7fd5a2b0e3 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -987,7 +987,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni if ( $this->isList()->yes() && $offsetType !== null - && $offsetType->toArrayKey()->isInteger()->yes() + && $this->getIterableKeyType()->isSuperTypeOf($offsetType)->yes() && $this->getIterableValueType()->isArray()->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 01aafa9790e..ff23c961940 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10089.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10089.php @@ -24,7 +24,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-14336.php b/tests/PHPStan/Analyser/nsrt/bug-14336.php new file mode 100644 index 00000000000..7486e5cac0b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14336.php @@ -0,0 +1,108 @@ + $list + * @param array $intMap + */ +function testAssignAnyIntInLoop(array $list, array $intMap): void +{ + foreach ($intMap as $intKey => $intValue) { + $list[$intKey] = ['abc' => 'def']; + } + assertType("array", $list); +} + +/** + * @param list $list + * @param int $intKey + */ +function testAssignAnyIntOutsideLoop(array $list, int $intKey): void +{ + $list[$intKey] = 'foo'; + assertType("non-empty-array", $list); +} + +/** + * Safe patterns should still preserve list. + * + * @param list $list + */ +function testKeepListWithAppend(array $list): void +{ + $list[] = 'foo'; + assertType("non-empty-list", $list); +} + +/** + * @param list $list + */ +function testKeepListWithConstantZero(array $list): void +{ + $list[0] = 'foo'; + assertType("non-empty-list&hasOffsetValue(0, 'foo')", $list); +} + +/** + * Nested array assignment in loop should keep outer list when key comes from iteration. + * + * @param list> $list + */ +function testNestedAssignKeepsList(array $list): void +{ + foreach ($list as $k => $v) { + $list[$k]['abc'] = 'world'; + } + assertType("list&hasOffsetValue('abc', 'world')>", $list); +} + +/** + * @param list> $list + * @param int $intKey + */ +function testNestedListAssignWithAnyInt(array $list, int $intKey): void +{ + $list[$intKey] = ['foo']; + assertType("non-empty-array>", $list); +} + +/** + * Assigning with negative int key should also degrade list. + * + * @param list $list + * @param int $negativeKey + */ +function testAssignNegativeInt(array $list, int $negativeKey): void +{ + $list[$negativeKey] = 'foo'; + assertType("non-empty-array", $list); +} + +/** + * Assigning with int<0, max> should still keep list (valid range). + * + * @param list> $list + * @param int<0, max> $nonNegativeKey + */ +function testAssignNonNegativeIntWithArrayValue(array $list, int $nonNegativeKey): void +{ + $list[$nonNegativeKey] = ['foo']; + assertType("non-empty-list>", $list); +} + +/** + * Direct scalar assignment with int<0, max> key. + * + * @param list $list + * @param int<0, max> $nonNegativeKey + */ +function testAssignNonNegativeIntWithScalarValue(array $list, int $nonNegativeKey): void +{ + $list[$nonNegativeKey] = 'foo'; + assertType("non-empty-array, string>", $list); +} From 9e394e18584f47e6cdae09acc0fc64f6410127ba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 16 Apr 2026 06:20:32 +0000 Subject: [PATCH 2/2] Fix nested dim fetch incorrectly preserving list type after container reset When processing nested array dim fetch assignments like `$arr[$key][$int] = $value` where the container was previously reset (`$arr[$key] = []`), the list type was incorrectly preserved. This happened because `produceArrayDimFetchAssignValueToWrite` used `setExistingOffsetValueType` (which preserves list structure) based on the general type from the parent's type system, while the actual tracked container type was an empty array where the offset couldn't exist. The fix adds a check at container levels (inner dimensions of nested dim fetches) to verify that the scope's actual tracked type of the container also supports the offset before using `setExistingOffsetValueType`. This correctly falls through to `setOffsetValueType` when the container was just reset to `[]`, which properly degrades the list type. This addresses the actual reproducer from phpstan/phpstan#14336 where the previous IntersectionType::setOffsetValueType fix alone was insufficient. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignHandler.php | 2 + tests/PHPStan/Analyser/nsrt/bug-14336.php | 48 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 51995ac6657..d8216a07ca6 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1057,11 +1057,13 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } $arrayDimFetch = $dimFetchStack[$i] ?? null; + $isContainerLevel = $i + 1 < count($dimFetchStack); if ( $offsetType !== null && $arrayDimFetch !== null && $scope->hasExpressionType($arrayDimFetch)->yes() && !$offsetValueType->hasOffsetValueType($offsetType)->no() + && (!$isContainerLevel || !$scope->getType($arrayDimFetch)->hasOffsetValueType($offsetType)->no()) ) { $hasOffsetType = null; if ($offsetType instanceof ConstantStringType || $offsetType instanceof ConstantIntegerType) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14336.php b/tests/PHPStan/Analyser/nsrt/bug-14336.php index 7486e5cac0b..2470a3b2554 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14336.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14336.php @@ -4,6 +4,54 @@ use function PHPStan\Testing\assertType; +/** + * Actual reproducer from the issue: nested dim fetch with arbitrary int key + * after resetting array element to []. + * + * @param array> $xsdFiles + * @param array> $groupedByNamespace + * @param array> $extraNamespaces + */ +function testIssueReproducer(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']; + + assertType('string', $xmlNamespace); + assertType('non-empty-list&hasOffsetValue(1, string)', $mergedNamespace); + + $xsdFiles[$xmlNamespace] = []; + foreach ($mergedNamespace as $namespace) { + foreach ($groupedByNamespace[$namespace] ?? [] as $viewHelper) { + $xsdFiles[$xmlNamespace][$int] = $viewHelper; + } + } + // After assigning any int, $xsdFiles[$xmlNamespace] should NOT be a list + assertType('array', $xsdFiles[$xmlNamespace]); + $xsdFiles[$xmlNamespace] = array_values($xsdFiles[$xmlNamespace]); + } +} + +/** + * Simplified: nested dim fetch after reset with arbitrary int key. + * + * @param array> $arr + */ +function testNestedDimFetchAfterReset(array $arr, int $int, string $key): void +{ + $arr[$key] = []; + $arr[$key][$int] = ['name' => 'test']; + assertType("non-empty-array", $arr[$key]); +} + /** * Assigning with arbitrary int key in a loop should degrade list to array. *