From 634b871af318e657937b8bb529a9b8207ffa4b5c Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:32:20 +0000 Subject: [PATCH] Degrade `isList` to `maybe` for optional non-list keys in array shapes and shape merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ConstantArrayTypeBuilder::setOffsetValueType()` now only forces `isList = no` for mandatory non-list-compatible keys; an optional such key degrades list-ness to `maybe` (`$this->isList->and(createMaybe())`), because the realization where the key is absent may still be a list. - Add `ConstantArrayType::inferIsListFromShape()` — computes the list-ness trinary of a sealed shape from its keys and optionality (`yes` iff every realization is a list, `no` iff none is, `maybe` otherwise), validated against a brute-force oracle. - Use it in `ConstantArrayType::mergeWith()` and `legacyMergeWith()` instead of the too-strict `$this->isList->and($otherArray->isList)`: merging widens one-sided keys into optional keys, so the result can admit list realizations (e.g. the empty array) that neither input did. Two pure lists still merge into a list (suffix-constrained), so `yes` is preserved in that case; only applied when the merged extras are sealed. - `ArrayType::isSuperTypeOf()` returns `maybe` instead of `no` for a constant array whose offending keys are all optional (it always admits the empty array, a subtype of every array type) — this is what makes `array_is_list()` narrowing reachable. Closes https://github.com/phpstan/phpstan/issues/14938 --- src/Type/ArrayType.php | 15 ++- src/Type/Constant/ConstantArrayType.php | 80 +++++++++++++++- .../Constant/ConstantArrayTypeBuilder.php | 8 +- .../nsrt/array-shape-list-optional.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-14938.php | 91 +++++++++++++++++++ 5 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14938.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index a951f13c24b..ddd9446d5a5 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -147,8 +147,21 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof self || $type instanceof ConstantArrayType) { - return $this->getItemType()->isSuperTypeOf($type->getItemType()) + $result = $this->getItemType()->isSuperTypeOf($type->getItemType()) ->and($this->getIterableKeyType()->isSuperTypeOf($type->getIterableKeyType())); + + if ( + $result->no() + && $type->isConstantArray()->yes() + && !$type->isIterableAtLeastOnce()->yes() + ) { + // A constant array whose offending keys/values are all optional + // still admits the empty array, which is a subtype of every + // array type — so the relationship is `maybe`, not `no`. + return IsSuperTypeOfResult::createMaybe(); + } + + return $result; } if ($type instanceof CompoundType) { diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 7eca2eaa587..c8e76eb3b50 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1437,6 +1437,57 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } + /** + * Compute the list-ness trinary of a sealed array shape purely from its keys + * and their optionality: `yes` if every realization (choice of which optional + * keys are present) is a list, `no` if none is, `maybe` otherwise. An optional + * key that breaks list-ness only degrades the answer to `maybe`, because the + * realization where that key is absent may still be a list. + * + * @param list $keyTypes + * @param int[] $optionalKeys + */ + private static function inferIsListFromShape(array $keyTypes, array $optionalKeys): TrinaryLogic + { + $optional = []; + foreach ($optionalKeys as $optionalKey) { + $optional[$optionalKey] = true; + } + + // Prefix lengths reachable by realizations that are still a valid list. + $validLengths = [0 => true]; + $existsInvalid = false; + + foreach ($keyTypes as $i => $keyType) { + $isOptional = array_key_exists($i, $optional); + $value = $keyType instanceof ConstantIntegerType ? $keyType->getValue() : null; + + $newValidLengths = []; + foreach (array_keys($validLengths) as $length) { + if ($isOptional) { + // Skipping the key keeps the realization a valid list prefix. + $newValidLengths[$length] = true; + } + + if ($value === $length) { + // Including the key extends the list into the next slot. + $newValidLengths[$length + 1] = true; + } else { + // Including a non-sequential key yields a non-list realization. + $existsInvalid = true; + } + } + + $validLengths = $newValidLengths; + if ($validLengths === []) { + // No realization can be a list from here on. + return TrinaryLogic::createNo(); + } + } + + return $existsInvalid ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); + } + /** * When we're unsetting something not on the array, it will be untouched, * So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition. @@ -3032,12 +3083,24 @@ public function mergeWith(self $otherArray): self /** @var list $keyTypes */ $keyTypes = $keyTypes; + // Merging widens keys present in only one side into optional keys, so the + // result can admit list realizations that neither input did. When the merged + // extras are the explicit-never sentinel (i.e. no real extras), the result is + // sealed and its list-ness follows purely from the merged shape. Two pure + // lists merge into a list (their optional keys are suffix-constrained), so + // keep `yes` in that case rather than degrading it from the shape. + $naiveIsList = $this->isList->and($otherArray->isList); + $mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit(); + $isList = $mergedIsSealed && !$naiveIsList->yes() + ? self::inferIsListFromShape($keyTypes, $optionalKeys) + : $naiveIsList; + return $this->recreate( $keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, - $this->isList->and($otherArray->isList), + $isList, $resultUnsealed, ); } @@ -3064,7 +3127,20 @@ private function legacyMergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); + // Merging widens keys present in only one side into optional keys, so the + // result can admit list realizations that neither input did (e.g. the empty + // array). When the result carries no real extras it is sealed and its + // list-ness follows purely from the merged shape, instead of the too-strict + // `$this->isList->and($otherArray->isList)`. Two pure lists merge into a list + // (their optional keys are suffix-constrained), so keep `yes` in that case. + $naiveIsList = $this->isList->and($otherArray->isList); + $mergedIsSealed = $this->unsealed === null + || ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit()); + $isList = $mergedIsSealed && !$naiveIsList->yes() + ? self::inferIsListFromShape($this->keyTypes, $optionalKeys) + : $naiveIsList; + + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $this->unsealed); } /** diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index e81a4d694eb..759d08ef3fe 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -224,11 +224,11 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt if ($offsetValue <= $max) { $this->isList = $this->isList->and(TrinaryLogic::createMaybe()); } else { - $this->isList = TrinaryLogic::createNo(); + $this->isList = $optional ? $this->isList->and(TrinaryLogic::createMaybe()) : TrinaryLogic::createNo(); } } } else { - $this->isList = TrinaryLogic::createNo(); + $this->isList = $optional ? $this->isList->and(TrinaryLogic::createMaybe()) : TrinaryLogic::createNo(); } if ($offsetValue >= $max) { @@ -245,10 +245,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } } } else { - $this->isList = TrinaryLogic::createNo(); + $this->isList = $optional ? $this->isList->and(TrinaryLogic::createMaybe()) : TrinaryLogic::createNo(); } } else { - $this->isList = TrinaryLogic::createNo(); + $this->isList = $optional ? $this->isList->and(TrinaryLogic::createMaybe()) : TrinaryLogic::createNo(); } if ($optional) { diff --git a/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php b/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php index 156ad93ed2c..116974abe04 100644 --- a/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php +++ b/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php @@ -25,8 +25,8 @@ public function doFoo( assertType('list{0: string, 1: int, 2?: string, 3?: string}', $valid1); assertType('list{0: string, 1?: int, 2?: string, 3?: string}', $valid2); assertType('non-empty-array{0?: string, 1?: int, 2?: string, 3?: string}', $valid3); - assertType('*NEVER*', $invalid1); - assertType('*NEVER*', $invalid2); + assertType('list{0: string, 1: int, 2?: string, 4?: string}', $invalid1); + assertType('list{0: string, 1: int, 2?: string, foo?: string}', $invalid2); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14938.php b/tests/PHPStan/Analyser/nsrt/bug-14938.php new file mode 100644 index 00000000000..0ac6d35027b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14938.php @@ -0,0 +1,91 @@ + 'z']; + if (rand(0, 1)) { + $b['y'] = 1; + } + assertType("array{0: 'z', y?: 1}", $b); + assertType('bool', array_is_list($b)); + + // Two pure lists still merge into a list. + $c = [0 => 'z']; + if (rand(0, 1)) { + $c[1] = 'w'; + } + assertType("array{0: 'z', 1?: 'w'}", $c); + assertType('true', array_is_list($c)); + + // Merging shapes disjoint save for the empty array still admits the empty list. + $d = []; + if (rand(0, 1)) { + $d['a'] = 1; + } + assertType('array{}|array{a: 1}', $d); + assertType('bool', array_is_list($d)); +}