From 830f4dbbf45a50cf4352f8dea594cb9a38b42c09 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 9 Jul 2026 02:57:58 +0900 Subject: [PATCH 1/5] An optional non-list key makes isList Maybe, not No MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ConstantArrayType whose only list-incompatible keys are optional can still be a list when those keys are absent (`array{a?: string}` admits `[]`), so its isList must be Maybe, not No. Previously any string / negative / gap key forced No regardless of optionality, which made `array_is_list()` report "always false" on such shapes. - ConstantArrayTypeBuilder: an optional list-incompatible key degrades isList Yes to Maybe (No stays No) via markNonListKey(). - ArrayType::isSuperTypeOf(): a possibly-empty constant array always admits `[]`, a subtype of every array type, so the relationship is at worst Maybe — never a definite No. This lets the `($value is list)` conditional of array_is_list() resolve to bool for possibly-empty shapes instead of false. - ConstantArrayType::makeList(): now that gap/string optional keys yield isList Maybe, intersecting a sealed such shape with list keeps only the contiguous 0..m prefix (the keys that can actually appear in a list) instead of collapsing to *NEVER*. Closes https://github.com/phpstan/phpstan/issues/14938 --- src/Type/ArrayType.php | 13 ++++- src/Type/Constant/ConstantArrayType.php | 33 +++++++++++ .../Constant/ConstantArrayTypeBuilder.php | 22 ++++++-- .../nsrt/array-shape-list-optional.php | 7 ++- tests/PHPStan/Analyser/nsrt/bug-14938.php | 56 +++++++++++++++++++ 5 files changed, 124 insertions(+), 7 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..895f07ae6f9 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -147,8 +147,19 @@ 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 possibly-empty constant array always admits the empty array, + // which is a subtype of every array type, so the relationship can + // only be "maybe", never a definite "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..31902b93434 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3168,6 +3168,39 @@ public function makeList(): Type return new NeverType(); } + // isList is Maybe: some values are lists, some are not. When the shape + // is sealed, a key sitting 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 such gaps, so there + // every key is kept. + if ($this->isUnsealed()->no()) { + $positionByIndex = []; + foreach ($this->keyTypes as $position => $keyType) { + if (!$keyType instanceof ConstantIntegerType) { + continue; + } + $positionByIndex[$keyType->getValue()] = $position; + } + + $keptPositions = []; + for ($index = 0; array_key_exists($index, $positionByIndex); $index++) { + $keptPositions[] = $positionByIndex[$index]; + } + + if (count($keptPositions) < count($this->keyTypes)) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($keptPositions as $position) { + $builder->setOffsetValueType( + $this->keyTypes[$position], + $this->valueTypes[$position], + $this->isOptionalKey($position), + ); + } + + return $builder->getArray(); + } + } + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index e81a4d694eb..aa24969c837 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->markNonListKey($optional); } } } else { - $this->isList = TrinaryLogic::createNo(); + $this->markNonListKey($optional); } if ($offsetValue >= $max) { @@ -245,10 +245,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } } } else { - $this->isList = TrinaryLogic::createNo(); + $this->markNonListKey($optional); } } else { - $this->isList = TrinaryLogic::createNo(); + $this->markNonListKey($optional); } if ($optional) { @@ -410,6 +410,20 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt $this->degradeToGeneralArray = true; } + /** + * Record that a key incompatible with list ordering was added. + * + * A required key definitely breaks list-ness. An optional key only might + * be present, so the array is still a list whenever the key is absent — + * degrade Yes to Maybe rather than to No (No stays No). + */ + private function markNonListKey(bool $optional): void + { + $this->isList = $optional + ? $this->isList->and(TrinaryLogic::createMaybe()) + : TrinaryLogic::createNo(); + } + public function degradeToGeneralArray(bool $oversized = false): void { if ($this->disableArrayDegradation) { diff --git a/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php b/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php index 156ad93ed2c..9acad79f69b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php +++ b/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php @@ -25,8 +25,11 @@ 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); + // The trailing keys can never appear in a list (4 sits past the gap at + // 3; foo is not an integer), so they are dropped, leaving the valid + // list projection rather than an empty *NEVER*. + assertType('array{0: string, 1: int, 2?: string}', $invalid1); + assertType('array{0: string, 1: int, 2?: 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..ccb99431815 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14938.php @@ -0,0 +1,56 @@ + Date: Thu, 9 Jul 2026 03:15:54 +0900 Subject: [PATCH 2/5] fixup! An optional non-list key makes isList Maybe, not No --- tests/PHPStan/Analyser/nsrt/bug-14938.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14938.php b/tests/PHPStan/Analyser/nsrt/bug-14938.php index ccb99431815..5ba68b7c3c6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14938.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14938.php @@ -49,6 +49,8 @@ public function narrowing(array $optStr): void if (array_is_list($optStr)) { assertType('list{}&list', $optStr); } else { + // array_is_list()'s false branch is not narrowed, so `a` stays optional + // here rather than being refined to array{a: string}. assertType('array{a?: string}', $optStr); } } From 7f213cad541e469a2d513a5c387b64e753d8ef45 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 9 Jul 2026 03:50:46 +0900 Subject: [PATCH 3/5] fixup! An optional non-list key makes isList Maybe, not No --- src/Type/Constant/ConstantArrayType.php | 80 ++++++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-14938.php | 28 ++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 31902b93434..a0a3763dc9a 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/tests/PHPStan/Analyser/nsrt/bug-14938.php b/tests/PHPStan/Analyser/nsrt/bug-14938.php index 5ba68b7c3c6..882b70b8ac0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14938.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14938.php @@ -55,4 +55,32 @@ public function narrowing(array $optStr): void } } + public function mergedShapes(): void + { + // Merging a list with a shape that adds a string key: the empty-of-the-extra + // realization is still a list, so array_is_list() is not decidable. + $a = [0 => 'z']; + if (rand(0, 1)) { + $a['y'] = 1; + } + assertType("array{0: 'z', y?: 1}", $a); + assertType('bool', array_is_list($a)); + + // Two pure lists merge into a list (optional keys stay a suffix). + $b = [0 => 'z']; + if (rand(0, 1)) { + $b[1] = 'w'; + } + assertType("array{0: 'z', 1?: 'w'}", $b); + assertType('true', array_is_list($b)); + + // Shapes disjoint except for the empty array still admit the empty list. + $c = []; + if (rand(0, 1)) { + $c['a'] = 1; + } + assertType('array{}|array{a: 1}', $c); + assertType('bool', array_is_list($c)); + } + } From af8a2ef89145d411473636a37c1b80325e3aa19e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 9 Jul 2026 04:34:54 +0900 Subject: [PATCH 4/5] fixup! An optional non-list key makes isList Maybe, not No --- src/Type/Constant/ConstantArrayType.php | 36 +++++------ .../Type/Constant/ConstantArrayTypeTest.php | 63 +++++++++++++++++++ 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index a0a3763dc9a..47654c7a27b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3083,17 +3083,17 @@ 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. + // Merging widens keys present in only one side into optional keys, so a + // sealed result may admit list realizations that neither input did (e.g. the + // empty array), which the naive `and` misses. Recompute list-ness from the + // merged shape in that case. Two pure lists still merge into a list, so keep + // `yes` there rather than degrading it from the shape's independent-optional + // reading; an unsealed result likewise can't be re-derived from known keys. $naiveIsList = $this->isList->and($otherArray->isList); $mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit(); - $isList = $mergedIsSealed && !$naiveIsList->yes() - ? self::inferIsListFromShape($keyTypes, $optionalKeys) - : $naiveIsList; + $isList = $naiveIsList->yes() || !$mergedIsSealed + ? $naiveIsList + : self::inferIsListFromShape($keyTypes, $optionalKeys); return $this->recreate( $keyTypes, @@ -3127,18 +3127,18 @@ private function legacyMergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - // 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. + // Merging widens keys present in only one side into optional keys, so a + // sealed result may admit list realizations that neither input did (e.g. the + // empty array), which the naive `and` misses. Recompute list-ness from the + // merged shape in that case. Two pure lists still merge into a list, so keep + // `yes` there rather than degrading it from the shape's independent-optional + // reading; an unsealed result likewise can't be re-derived from known keys. $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; + $isList = $naiveIsList->yes() || !$mergedIsSealed + ? $naiveIsList + : self::inferIsListFromShape($this->keyTypes, $optionalKeys); return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $this->unsealed); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 5c85f860264..bf9bf69951a 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1639,6 +1639,69 @@ public function testSealedness(): void }); } + /** + * @param list $items + */ + private function buildShape(array $items): ConstantArrayType + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($items as [$key, $valueType, $optional]) { + $keyType = is_string($key) ? new ConstantStringType($key) : new ConstantIntegerType($key); + $builder->setOffsetValueType($keyType, $valueType, $optional); + } + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + + return $array; + } + + public function testMakeListProjectsSealedShapeButKeepsUnsealedKeys(): void + { + // A sealed shape with an optional key past the gap at 1 (2? here): key 2 can + // never appear in a list, so makeList() drops it and projects to the prefix. + BleedingEdgeToggle::withBleedingEdge(true, function (): void { + $array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe()); + $this->assertSame('array{int}', $array->makeList()->describe(VerbosityLevel::precise())); + }); + + // Unsealed: extras may fill the gap, so key 2 is reachable and every key is kept. + BleedingEdgeToggle::withBleedingEdge(false, function (): void { + $array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe()); + $this->assertSame('array{0: int, 2?: string}', $array->makeList()->describe(VerbosityLevel::precise())); + }); + } + + public function testMergeWithRecomputesListnessOfSealedShape(): void + { + // main mergeWith() path (bleeding edge: shapes carry real unsealed markers) + BleedingEdgeToggle::withBleedingEdge(true, function (): void { + // Two pure lists merge into a list, even though the merged shape read with + // independent optional keys would over-approximate to maybe. + $x = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), true]]); + $y = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), false], [2, new IntegerType(), true]]); + $this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe()); + + // A mandatory non-list key that becomes optional through merging turns the + // naive `no` into `maybe` (the merged shape now admits list realizations). + $x2 = $this->buildShape([[0, new IntegerType(), false], ['a', new StringType(), false]]); + $y2 = $this->buildShape([[0, new IntegerType(), false]]); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $x2->mergeWith($y2)->isList()->describe()); + }); + + // legacyMergeWith() path (unsealed === null) + BleedingEdgeToggle::withBleedingEdge(false, function (): void { + $x = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), false], [2, new StringType(), false]]); + $y = $this->buildShape([[0, new IntegerType(), false]]); + $this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe()); + + $x2 = $this->buildShape([[0, new IntegerType(), false], ['a', new StringType(), false]]); + $y2 = $this->buildShape([[0, new IntegerType(), false]]); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $x2->mergeWith($y2)->isList()->describe()); + }); + } + public static function dataGetArraySize(): iterable { $cases = []; From 702067fedb68953bd43d1959cd56cbd37449605b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 9 Jul 2026 05:29:52 +0900 Subject: [PATCH 5/5] fixup! An optional non-list key makes isList Maybe, not No --- src/Type/Constant/ConstantArrayType.php | 35 +++++++++---------- .../Type/Constant/ConstantArrayTypeTest.php | 8 +++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 47654c7a27b..272cbec8da9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3083,17 +3083,16 @@ public function mergeWith(self $otherArray): self /** @var list $keyTypes */ $keyTypes = $keyTypes; - // Merging widens keys present in only one side into optional keys, so a - // sealed result may admit list realizations that neither input did (e.g. the - // empty array), which the naive `and` misses. Recompute list-ness from the - // merged shape in that case. Two pure lists still merge into a list, so keep - // `yes` there rather than degrading it from the shape's independent-optional - // reading; an unsealed result likewise can't be re-derived from known keys. + // A key present on only one side becomes optional, so a sealed result may gain + // list realizations (e.g. the empty array) the naive `and` misses; fold in the + // list-ness recomputed from the merged shape. `or` keeps a naive `yes` (two + // lists merge into a list even where the shape's independent-optional reading + // would say maybe) while letting the shape lift a `no`/`maybe`. $naiveIsList = $this->isList->and($otherArray->isList); $mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit(); - $isList = $naiveIsList->yes() || !$mergedIsSealed - ? $naiveIsList - : self::inferIsListFromShape($keyTypes, $optionalKeys); + $isList = $mergedIsSealed + ? $naiveIsList->or(self::inferIsListFromShape($keyTypes, $optionalKeys)) + : $naiveIsList; return $this->recreate( $keyTypes, @@ -3127,18 +3126,18 @@ private function legacyMergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - // Merging widens keys present in only one side into optional keys, so a - // sealed result may admit list realizations that neither input did (e.g. the - // empty array), which the naive `and` misses. Recompute list-ness from the - // merged shape in that case. Two pure lists still merge into a list, so keep - // `yes` there rather than degrading it from the shape's independent-optional - // reading; an unsealed result likewise can't be re-derived from known keys. + // This path keeps only `$this`'s keys, widening those missing from the other + // side to optional, so a sealed result may gain list realizations the naive + // `and` misses — including lifting a `maybe` back to `yes` when the widened + // keys form a valid list suffix. Fold in the list-ness of the merged shape; + // `or` keeps a naive `yes` unchanged, and an unsealed result can't be + // re-derived from the known keys alone. $naiveIsList = $this->isList->and($otherArray->isList); $mergedIsSealed = $this->unsealed === null || ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit()); - $isList = $naiveIsList->yes() || !$mergedIsSealed - ? $naiveIsList - : self::inferIsListFromShape($this->keyTypes, $optionalKeys); + $isList = $mergedIsSealed + ? $naiveIsList->or(self::inferIsListFromShape($this->keyTypes, $optionalKeys)) + : $naiveIsList; return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $this->unsealed); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index bf9bf69951a..6e78df2e6de 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1699,6 +1699,14 @@ public function testMergeWithRecomputesListnessOfSealedShape(): void $x2 = $this->buildShape([[0, new IntegerType(), false], ['a', new StringType(), false]]); $y2 = $this->buildShape([[0, new IntegerType(), false]]); $this->assertSame(TrinaryLogic::createMaybe()->describe(), $x2->mergeWith($y2)->isList()->describe()); + + // Legacy merge keeps only $this's keys, so a naive `maybe` (the other side + // has a gap key) sharpens back to `yes`: the surviving keys form a list once + // the key absent from the other side is widened to a trailing optional. + $x3 = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), false]]); + $y3 = $this->buildShape([[0, new IntegerType(), false], [5, new StringType(), true]]); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $y3->isList()->describe()); + $this->assertSame(TrinaryLogic::createYes()->describe(), $x3->mergeWith($y3)->isList()->describe()); }); }