From f89573d8f73efabe1b890c1fc6776981deb9ec94 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 07:50:57 +0900 Subject: [PATCH 1/3] Put the keys of a list in ascending order and require the ones below a required key makeList() turned a maybe-list shape into a list by flipping its isList flag, keeping the keys in the order they were written and their optionality as it was. A list has its keys in ascending order and one with the key k has every key below k, and positional operations such as array_values() trust both - so array{1?: int, 0?: string} narrowed by array_is_list() became list{1?: int, 0?: string} and its array_values() list{0?: int, 1?: string}, the value types swapped. The keys are now put in ascending order (the integer ones first where unsealed extras keep every key) and the ones below the highest required key are made required, in sealed shapes and in shapes without the unsealed marker alike. Co-authored-by: Claude Opus 5.5 --- src/Type/Constant/ConstantArrayType.php | 84 ++++++++++++++----- tests/PHPStan/Analyser/nsrt/bug-14177.php | 3 +- .../Analyser/nsrt/make-list-key-order.php | 39 +++++++++ .../Type/Constant/ConstantArrayTypeTest.php | 84 +++++++++++++++++++ 4 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/make-list-key-order.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 5f95b0d6ce0..84a0efbb0b9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -82,6 +82,7 @@ use function in_array; use function is_int; use function is_string; +use function ksort; use function max; use function min; use function pow; @@ -3246,36 +3247,77 @@ public function makeList(): Type return new NeverType(); } - // isList is Maybe. In a sealed shape a key past a gap in the 0..n sequence - // (or any non-integer key) can never appear in a list, so keep only the - // contiguous 0..m prefix. Unsealed extras may fill the gaps, so keep every - // key there. - if ($this->isUnsealed()->no()) { - $positionByIndex = []; - foreach ($this->keyTypes as $position => $keyType) { - if (!$keyType instanceof ConstantIntegerType) { - continue; - } - $positionByIndex[$keyType->getValue()] = $position; + // isList is Maybe. A list has its keys in ascending order, and one that + // has the key k has every key below k too - positional operations on the + // list trust both, so the keys are put in ascending order and the ones + // below a required key are made required. In a sealed shape a key past a + // gap in the 0..n sequence (or any non-integer key) can never appear in a + // list, so keep only the contiguous 0..m prefix. Unsealed extras may fill + // the gaps, so keep every key there, the integer ones first. + $positionByIndex = []; + $otherPositions = []; + foreach ($this->keyTypes as $position => $keyType) { + if (!$keyType instanceof ConstantIntegerType) { + $otherPositions[] = $position; + continue; } + $positionByIndex[$keyType->getValue()] = $position; + } + if ($this->isUnsealed()->no()) { $keptPositions = []; for ($index = 0; array_key_exists($index, $positionByIndex); $index++) { $keptPositions[] = $positionByIndex[$index]; } + } else { + ksort($positionByIndex); + $keptPositions = array_merge(array_values($positionByIndex), $otherPositions); + } - if (count($keptPositions) < count($this->keyTypes)) { - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($keptPositions as $position) { - $builder->setOffsetValueType( - $this->keyTypes[$position], - $this->valueTypes[$position], - $this->isOptionalKey($position), - ); - } + $requiredLength = 0; + foreach ($keptPositions as $position) { + $keyType = $this->keyTypes[$position]; + if (!$keyType instanceof ConstantIntegerType || $this->isOptionalKey($position)) { + continue; + } - return $builder->getArray(); + $requiredLength = max($requiredLength, $keyType->getValue() + 1); + } + + $optionalKeys = []; + foreach ($keptPositions as $newPosition => $position) { + if (!$this->isOptionalKey($position)) { + continue; } + + $keyType = $this->keyTypes[$position]; + if ($keyType instanceof ConstantIntegerType && $keyType->getValue() >= 0 && $keyType->getValue() < $requiredLength) { + continue; + } + + $optionalKeys[] = $newPosition; + } + + if ($keptPositions !== array_keys($this->keyTypes) || count($optionalKeys) !== count($this->optionalKeys)) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->disableArrayDegradation(); + foreach ($keptPositions as $newPosition => $position) { + $builder->setOffsetValueType( + $this->keyTypes[$position], + $this->valueTypes[$position], + in_array($newPosition, $optionalKeys, true), + ); + } + + $constantArrays = $builder->getArray()->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException(); + } + + // The builder can answer maybe for an optional tail like `[0, 1?, 2?]`, + // but these are the keys of a list. + $list = $constantArrays[0]; + return $this->recreate($list->keyTypes, $list->valueTypes, $list->nextAutoIndexes, $list->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 26ef94e5577..37725d01cff 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -184,7 +184,8 @@ public function testUnsetNonConstant(array $a, array $b, array $c, int $int): vo unset($c[$int]); assertType('false', array_is_list($a)); assertType('bool', array_is_list($b)); - assertType('bool', array_is_list($c)); + // the required key 3 makes the keys below it required in a list too + assertType('false', array_is_list($c)); } /** diff --git a/tests/PHPStan/Analyser/nsrt/make-list-key-order.php b/tests/PHPStan/Analyser/nsrt/make-list-key-order.php new file mode 100644 index 00000000000..ebe816d5a85 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/make-list-key-order.php @@ -0,0 +1,39 @@ +, bool, string, list}> + */ + public static function dataMakeListPutsKeysInAscendingOrderAndRequiresTheOnesBelowARequiredKey(): iterable + { + foreach ([true, false] as $bleedingEdge) { + $suffix = $bleedingEdge ? ' (sealed)' : ' (unsealed === null)'; + + yield 'descending keys' . $suffix => [ + $bleedingEdge, + [[1, new IntegerType(), true], [0, new StringType(), true]], + false, + 'list{0?: string, 1?: int}', + [0, 1], + ]; + + yield 'optional key below a required one' . $suffix => [ + $bleedingEdge, + [[0, new StringType(), true], [1, new IntegerType(), false]], + false, + 'array{string, int}', + [0, 1], + ]; + } + + // Unsealed extras may fill the gaps, so a string key and a negative key + // stay (after the integer ones and optional), unlike in a sealed shape. + yield 'string key after the integer ones (unsealed === null)' => [ + false, + [[1, new IntegerType(), true], ['a', new BooleanType(), true], [0, new StringType(), true]], + false, + 'list{0?: string, 1?: int, a?: bool}', + [0, 1, 'a'], + ]; + + yield 'negative key stays optional (unsealed === null)' => [ + false, + [[-1, new BooleanType(), true], [0, new StringType(), true], [1, new IntegerType(), false]], + false, + 'list{-1?: bool, 0: string, 1: int}', + [-1, 0, 1], + ]; + + yield 'real extras are kept' => [ + true, + [[1, new IntegerType(), true], [0, new StringType(), true]], + true, + 'list{0?: string, 1?: int, ...}', + [0, 1], + ]; + } + + /** + * @param list $items + * @param list $expectedKeys + */ + #[DataProvider('dataMakeListPutsKeysInAscendingOrderAndRequiresTheOnesBelowARequiredKey')] + public function testMakeListPutsKeysInAscendingOrderAndRequiresTheOnesBelowARequiredKey( + bool $bleedingEdge, + array $items, + bool $unsealed, + string $expectedList, + array $expectedKeys, + ): void + { + BleedingEdgeToggle::withBleedingEdge($bleedingEdge, function () use ($items, $unsealed, $expectedList, $expectedKeys): void { + $array = $this->buildShape($items); + if ($unsealed) { + $builder = ConstantArrayTypeBuilder::createFromConstantArray($array); + $builder->makeUnsealed(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new BooleanType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + } + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe()); + + $list = $array->makeList(); + $this->assertInstanceOf(ConstantArrayType::class, $list); + $this->assertSame($expectedList, $list->describe(VerbosityLevel::precise())); + $this->assertSame(TrinaryLogic::createYes()->describe(), $list->isList()->describe()); + $this->assertSame($expectedKeys, array_map(static fn (ConstantIntegerType|ConstantStringType $key): int|string => $key->getValue(), $list->getKeyTypes())); + $this->assertSame($array->isUnsealed()->describe(), $list->isUnsealed()->describe()); + }); + } + /** * @return iterable, list, TrinaryLogic}> */ From a32938e47f21e7f96e1ce739679f64a309666521 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 08:54:33 +0900 Subject: [PATCH 2/3] Reach makeList() from list intersections and drop the keys a list cannot have array_is_list() narrows by intersecting with list. A shape that is not a subtype of array, mixed> - one with a string or a negative key, or with extras - is rebuilt against that array first, and the rebuilt shape moved on without meeting the list accessory, so makeList() never ran: array{a?: bool, 1?: int, 0?: string} still became list{1?: int, 0?: string}. The rebuilt shape now meets the remaining members again. makeList() now also drops the negative and non-integer keys everywhere, not only in sealed shapes - keeping a negative key in front made the non-empty-list rule require it instead of the key 0 - and a shape whose dropped keys include a required one is never a list. Co-authored-by: Claude Opus 5.5 --- src/Type/Constant/ConstantArrayType.php | 53 +++++++------ src/Type/TypeCombinator.php | 7 +- .../Analyser/nsrt/make-list-key-order.php | 32 ++++++++ .../Type/Constant/ConstantArrayTypeTest.php | 75 +++++++++++++++++-- 4 files changed, 134 insertions(+), 33 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 84a0efbb0b9..00979d3c273 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -67,6 +67,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_flip; use function array_key_exists; use function array_keys; use function array_map; @@ -3250,62 +3251,68 @@ public function makeList(): Type // isList is Maybe. A list has its keys in ascending order, and one that // has the key k has every key below k too - positional operations on the // list trust both, so the keys are put in ascending order and the ones - // below a required key are made required. In a sealed shape a key past a - // gap in the 0..n sequence (or any non-integer key) can never appear in a - // list, so keep only the contiguous 0..m prefix. Unsealed extras may fill - // the gaps, so keep every key there, the integer ones first. + // below a required key are made required. A list never has a negative or + // a non-integer key, so those are dropped. In a sealed shape a key past a + // gap in the 0..n sequence can never appear in a list either, so only the + // contiguous 0..m prefix is kept; unsealed extras may fill the gaps, so + // every other key is kept there. $positionByIndex = []; - $otherPositions = []; + $droppedPositions = []; foreach ($this->keyTypes as $position => $keyType) { - if (!$keyType instanceof ConstantIntegerType) { - $otherPositions[] = $position; + if (!$keyType instanceof ConstantIntegerType || $keyType->getValue() < 0) { + $droppedPositions[] = $position; continue; } $positionByIndex[$keyType->getValue()] = $position; } + ksort($positionByIndex); if ($this->isUnsealed()->no()) { $keptPositions = []; for ($index = 0; array_key_exists($index, $positionByIndex); $index++) { $keptPositions[] = $positionByIndex[$index]; + unset($positionByIndex[$index]); + } + foreach ($positionByIndex as $position) { + $droppedPositions[] = $position; } } else { - ksort($positionByIndex); - $keptPositions = array_merge(array_values($positionByIndex), $otherPositions); + $keptPositions = array_values($positionByIndex); + } + + foreach ($droppedPositions as $position) { + if (!$this->isOptionalKey($position)) { + return new NeverType(); + } } + $indexByPosition = array_flip($keptPositions); $requiredLength = 0; - foreach ($keptPositions as $position) { - $keyType = $this->keyTypes[$position]; - if (!$keyType instanceof ConstantIntegerType || $this->isOptionalKey($position)) { + foreach ($keptPositions as $index => $position) { + if ($this->isOptionalKey($position)) { continue; } - $requiredLength = max($requiredLength, $keyType->getValue() + 1); + $requiredLength = $index + 1; } - $optionalKeys = []; + $optionalPositions = []; foreach ($keptPositions as $newPosition => $position) { - if (!$this->isOptionalKey($position)) { - continue; - } - - $keyType = $this->keyTypes[$position]; - if ($keyType instanceof ConstantIntegerType && $keyType->getValue() >= 0 && $keyType->getValue() < $requiredLength) { + if (!$this->isOptionalKey($position) || $indexByPosition[$position] < $requiredLength) { continue; } - $optionalKeys[] = $newPosition; + $optionalPositions[$newPosition] = true; } - if ($keptPositions !== array_keys($this->keyTypes) || count($optionalKeys) !== count($this->optionalKeys)) { + if ($keptPositions !== array_keys($this->keyTypes) || count($optionalPositions) !== count($this->optionalKeys)) { $builder = ConstantArrayTypeBuilder::createEmpty(); $builder->disableArrayDegradation(); foreach ($keptPositions as $newPosition => $position) { $builder->setOffsetValueType( $this->keyTypes[$position], $this->valueTypes[$position], - in_array($newPosition, $optionalKeys, true), + isset($optionalPositions[$newPosition]), ); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index cd08b5615f0..a25ddb20478 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -2085,8 +2085,11 @@ public static function doIntersect(Type ...$types): Type } if ($constArrayIsI) { - $types[$i] = $newArrayType; - array_splice($types, $j--, 1); + // The rebuilt shape meets the other members from the + // start again - the list accessory of `list` can + // come after the array it is paired with. + $types[$i--] = $newArrayType; + array_splice($types, $j, 1); } else { $types[$j] = $newArrayType; array_splice($types, $i--, 1); diff --git a/tests/PHPStan/Analyser/nsrt/make-list-key-order.php b/tests/PHPStan/Analyser/nsrt/make-list-key-order.php index ebe816d5a85..4a74a17f5fe 100644 --- a/tests/PHPStan/Analyser/nsrt/make-list-key-order.php +++ b/tests/PHPStan/Analyser/nsrt/make-list-key-order.php @@ -19,6 +19,38 @@ public function keysInAscendingOrder(array $descending): void } } + /** + * @param array{a?: bool, 1?: int, 0?: string} $withStringKey + * @param array{-1?: bool, 0?: string, 1: int} $withNegativeKey + * @param array{1?: int, 0?: string, ...} $withExtras + */ + public function keysThatAListCannotHave(array $withStringKey, array $withNegativeKey, array $withExtras): void + { + if (array_is_list($withStringKey)) { + assertType('list{0?: string, 1?: int}', $withStringKey); + assertType('list{0?: string, 1?: int}', array_values($withStringKey)); + } + + if (array_is_list($withNegativeKey)) { + assertType('array{string, int}', $withNegativeKey); + } + + if (array_is_list($withExtras)) { + assertType('list{0?: string, 1?: int, ...}', $withExtras); + } + } + + /** + * @param array{0?: string, 2?: int, 3: bool} $requiredPastAGap + */ + public function requiredKeyPastAGap(array $requiredPastAGap): void + { + if (array_is_list($requiredPastAGap)) { + // a list with the key 3 has the key 1 too, which this shape never has + assertType('*NEVER*', $requiredPastAGap); + } + } + /** * @param array{0?: string, 1: int} $optionalBelowRequired * @param list{0: string, 1?: string, 2?: string, 3: string} $gapInList diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index ecc82ffca73..f17eac8431d 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -7,8 +7,10 @@ use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; +use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; @@ -1705,22 +1707,22 @@ public static function dataMakeListPutsKeysInAscendingOrderAndRequiresTheOnesBel ]; } - // Unsealed extras may fill the gaps, so a string key and a negative key - // stay (after the integer ones and optional), unlike in a sealed shape. - yield 'string key after the integer ones (unsealed === null)' => [ + // A list never has a non-integer or a negative key, even where unsealed + // extras keep the keys past a gap. + yield 'string key is dropped (unsealed === null)' => [ false, [[1, new IntegerType(), true], ['a', new BooleanType(), true], [0, new StringType(), true]], false, - 'list{0?: string, 1?: int, a?: bool}', - [0, 1, 'a'], + 'list{0?: string, 1?: int}', + [0, 1], ]; - yield 'negative key stays optional (unsealed === null)' => [ + yield 'negative key is dropped (unsealed === null)' => [ false, [[-1, new BooleanType(), true], [0, new StringType(), true], [1, new IntegerType(), false]], false, - 'list{-1?: bool, 0: string, 1: int}', - [-1, 0, 1], + 'array{string, int}', + [0, 1], ]; yield 'real extras are kept' => [ @@ -1764,6 +1766,63 @@ public function testMakeListPutsKeysInAscendingOrderAndRequiresTheOnesBelowARequ }); } + /** + * @return iterable, bool, string}> + */ + public static function dataIntersectWithListPutsKeysInAscendingOrder(): iterable + { + foreach ([true, false] as $bleedingEdge) { + $suffix = $bleedingEdge ? ' (sealed)' : ' (unsealed === null)'; + + // these shapes are not subtypes of array, mixed>, so the + // intersection rebuilds them before they meet the list accessory + yield 'string key' . $suffix => [ + $bleedingEdge, + [['a', new BooleanType(), true], [1, new IntegerType(), true], [0, new StringType(), true]], + false, + 'list{0?: string, 1?: int}', + ]; + + yield 'negative key' . $suffix => [ + $bleedingEdge, + [[-1, new BooleanType(), true], [0, new StringType(), true], [1, new IntegerType(), false]], + false, + 'array{string, int}', + ]; + + // the rule that requires the first optional key of a non-empty list + // must see the key 0 first, not the negative one + yield 'negative key in a non-empty list' . $suffix => [ + $bleedingEdge, + [[0, new StringType(), true], [-1, new BooleanType(), true]], + true, + 'array{string}', + ]; + } + } + + /** + * @param list $items + */ + #[DataProvider('dataIntersectWithListPutsKeysInAscendingOrder')] + public function testIntersectWithListPutsKeysInAscendingOrder(bool $bleedingEdge, array $items, bool $nonEmpty, string $expected): void + { + BleedingEdgeToggle::withBleedingEdge($bleedingEdge, function () use ($items, $nonEmpty, $expected): void { + $types = [ + $this->buildShape($items), + new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new MixedType()), + new AccessoryArrayListType(), + ]; + if ($nonEmpty) { + $types[] = new NonEmptyArrayType(); + } + + $list = TypeCombinator::intersect(...$types); + $this->assertSame($expected, $list->describe(VerbosityLevel::precise())); + $this->assertSame(TrinaryLogic::createYes()->describe(), $list->isList()->describe()); + }); + } + /** * @return iterable, list, TrinaryLogic}> */ From b459b95556be4e5c60de42029bffda1a3b6e67be Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 18:29:52 +0900 Subject: [PATCH 3/3] fixup! Put the keys of a list in ascending order and require the ones below a required key --- tests/PHPStan/Analyser/nsrt/bug-14177.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 37725d01cff..2b3a7f932a5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -184,7 +184,7 @@ public function testUnsetNonConstant(array $a, array $b, array $c, int $int): vo unset($c[$int]); assertType('false', array_is_list($a)); assertType('bool', array_is_list($b)); - // the required key 3 makes the keys below it required in a list too + // in a list, the required key 3 implies the keys 1 and 2, so unsetting either leaves no list assertType('false', array_is_list($c)); }