From 2aed6538d6b6a77dbe97e2f4a3d57722eaa50efe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:11:54 +0000 Subject: [PATCH] Handle ksort/krsort key reordering for constant arrays - ksort/krsort now reorder ConstantArrayType entries by key value - After ksort, array_values() returns values in the correct sorted order - Correctly computes isList for sorted arrays (ksort on a list stays a list) - Updated existing bug-10627 test expectations to match correct behavior - New regression test in tests/PHPStan/Analyser/nsrt/bug-11569.php Closes https://github.com/phpstan/phpstan/issues/11569 --- src/Analyser/NodeScopeResolver.php | 96 ++++++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-10627.php | 8 +- tests/PHPStan/Analyser/nsrt/bug-11569.php | 74 +++++++++++++++++ 3 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11569.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 86f616953b..7ed53abcd4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -208,6 +208,7 @@ use UnhandledMatchError; use function array_fill_keys; use function array_filter; +use function array_flip; use function array_key_exists; use function array_key_last; use function array_keys; @@ -3049,7 +3050,25 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto if ( $functionReflection !== null - && in_array($functionReflection->getName(), ['natcasesort', 'natsort', 'arsort', 'asort', 'ksort', 'krsort', 'uasort', 'uksort'], true) + && in_array($functionReflection->getName(), ['ksort', 'krsort'], true) + && count($normalizedExpr->getArgs()) >= 1 + ) { + $arrayArg = $normalizedExpr->getArgs()[0]->value; + $reverse = $functionReflection->getName() === 'krsort'; + + $scope = $this->processVirtualAssign( + $scope, + $storage, + $stmt, + $arrayArg, + new NativeTypeExpr($this->getArrayKsortFunctionType($scope->getType($arrayArg), $reverse), $this->getArrayKsortFunctionType($scope->getNativeType($arrayArg), $reverse)), + $nodeCallback, + )->getScope(); + } + + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['natcasesort', 'natsort', 'arsort', 'asort', 'uasort', 'uksort'], true) && count($normalizedExpr->getArgs()) >= 1 ) { $arrayArg = $normalizedExpr->getArgs()[0]->value; @@ -4734,6 +4753,81 @@ private function getArraySortDoNotPreserveListFunctionType(Type $type): Type }); } + private function getArrayKsortFunctionType(Type $type, bool $reverse): Type + { + $isIterableAtLeastOnce = $type->isIterableAtLeastOnce(); + if ($isIterableAtLeastOnce->no()) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($isIterableAtLeastOnce, $reverse): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + $types = []; + foreach ($constantArrays as $constantArray) { + $keyTypes = $constantArray->getKeyTypes(); + $valueTypes = $constantArray->getValueTypes(); + $optionalKeys = $constantArray->getOptionalKeys(); + + $indices = array_keys($keyTypes); + usort($indices, static function (int $a, int $b) use ($keyTypes, $reverse): int { + $keyA = $keyTypes[$a]->getValue(); + $keyB = $keyTypes[$b]->getValue(); + $result = $keyA <=> $keyB; + return $reverse ? -$result : $result; + }); + + $sortedKeyTypes = []; + $sortedValueTypes = []; + $newOptionalKeys = []; + $optionalKeysSet = array_flip($optionalKeys); + foreach ($indices as $newIndex => $oldIndex) { + $sortedKeyTypes[] = $keyTypes[$oldIndex]; + $sortedValueTypes[] = $valueTypes[$oldIndex]; + if (!isset($optionalKeysSet[$oldIndex])) { + continue; + } + + $newOptionalKeys[] = $newIndex; + } + + $isList = TrinaryLogic::createNo(); + $isSequential = true; + foreach ($sortedKeyTypes as $i => $keyType) { + if (!$keyType instanceof ConstantIntegerType || $keyType->getValue() !== $i) { + $isSequential = false; + break; + } + } + if ($isSequential) { + $isList = $constantArray->isList(); + } + + $types[] = new ConstantArrayType( + $sortedKeyTypes, + $sortedValueTypes, + $constantArray->getNextAutoIndexes(), + $newOptionalKeys, + $isList, + ); + } + + return TypeCombinator::union(...$types); + } + + $newArrayType = new ArrayType($type->getIterableKeyType(), $type->getIterableValueType()); + if ($isIterableAtLeastOnce->yes()) { + $newArrayType = new IntersectionType([$newArrayType, new NonEmptyArrayType()]); + } + + return $newArrayType; + }); + } + private function getFunctionThrowPoint( FunctionReflection $functionReflection, ?ParametersAcceptor $parametersAcceptor, diff --git a/tests/PHPStan/Analyser/nsrt/bug-10627.php b/tests/PHPStan/Analyser/nsrt/bug-10627.php index 17579ec52c..60ac292559 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10627.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10627.php @@ -44,7 +44,7 @@ public function sayHello5(): void $list = ['A', 'C', 'B']; ksort($list); assertType("array{'A', 'C', 'B'}", $list); - assertType('bool', array_is_list($list)); + assertType('true', array_is_list($list)); } public function sayHello6(): void @@ -71,8 +71,8 @@ public function sayHello8(): void { $list = ['A', 'C', 'B']; krsort($list); - assertType("array{'A', 'C', 'B'}", $list); - assertType('bool', array_is_list($list)); + assertType("array{2: 'B', 1: 'C', 0: 'A'}", $list); + assertType('false', array_is_list($list)); } /** @@ -89,7 +89,7 @@ public function sayHello10(): void { $list = ['a' => 'A', 'c' => 'C', 'b' => 'B']; krsort($list); - assertType("array{a: 'A', c: 'C', b: 'B'}", $list); + assertType("array{c: 'C', b: 'B', a: 'A'}", $list); assertType('false', array_is_list($list)); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11569.php b/tests/PHPStan/Analyser/nsrt/bug-11569.php new file mode 100644 index 0000000000..c66828c107 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11569.php @@ -0,0 +1,74 @@ + 'John Doe', 'age' => 30]; + } + + public function testKsort(): void + { + $value = ['name' => 'John Doe', 'age' => 30]; + ksort($value); + assertType("array{age: 30, name: 'John Doe'}", $value); + } + + public function testKrsort(): void + { + $value = ['name' => 'John Doe', 'age' => 30]; + krsort($value); + assertType("array{name: 'John Doe', age: 30}", $value); + } + + public function testKsortWithArrayValues(): void + { + $data = $this->getFoo(); + ksort($data); + assertType('array{age: int, name: string}', $data); + $values = array_values($data); + assertType('array{int, string}', $values); + } + + public function testKsortWithArrayCombine(): void + { + $data = $this->getFoo(); + ksort($data); + $values = array_values($data); + $result = array_combine(['age', 'name'], $values); + assertType('array{age: int, name: string}', $result); + } + + public function testKsortIntegerKeys(): void + { + $list = ['A', 'C', 'B']; + ksort($list); + assertType("array{'A', 'C', 'B'}", $list); + } + + public function testKrsortIntegerKeys(): void + { + $list = ['A', 'C', 'B']; + krsort($list); + assertType("array{2: 'B', 1: 'C', 0: 'A'}", $list); + } + + public function testKsortStringKeys(): void + { + $value = ['c' => 3, 'a' => 1, 'b' => 2]; + ksort($value); + assertType('array{a: 1, b: 2, c: 3}', $value); + } + + public function testKrsortStringKeys(): void + { + $value = ['c' => 3, 'a' => 1, 'b' => 2]; + krsort($value); + assertType('array{c: 3, b: 2, a: 1}', $value); + } +}