From 953a101966daa5764a942ae0444ea1e4b555298e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 9 Jul 2026 02:57:58 +0900 Subject: [PATCH 1/3] 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 | 12 +- src/Type/Constant/ConstantArrayType.php | 107 ++++++++++++++++- .../Constant/ConstantArrayTypeBuilder.php | 20 +++- .../nsrt/array-shape-list-optional.php | 7 +- tests/PHPStan/Analyser/nsrt/bug-14938.php | 86 ++++++++++++++ .../Type/Constant/ConstantArrayTypeTest.php | 109 ++++++++++++++++++ 6 files changed, 332 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 1eb12c177a2..300f5ae7875 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -148,8 +148,18 @@ 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 admits `[]`, a subtype of every + // array type, so the relationship is at worst `maybe`, never `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 ff58ecdc86f..cfb3e35d529 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1439,6 +1439,56 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } + /** + * List-ness of a sealed shape from its keys and optionality: `yes` if every + * realization (choice of present optional keys) is a list, `no` if none is, + * `maybe` otherwise. + * + * @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); + // A numeric-string key like "1" is an integer key at runtime, so + // normalize before deciding whether it continues the list. + $arrayKey = $keyType->toArrayKey(); + $value = $arrayKey instanceof ConstantIntegerType ? $arrayKey->getValue() : null; + + $newValidLengths = []; + foreach (array_keys($validLengths) as $length) { + if ($isOptional) { + $newValidLengths[$length] = true; + } + + // A key equal to the current length extends the prefix; anything + // else is a non-list realization. + if ($value === $length) { + $newValidLengths[$length + 1] = true; + } else { + $existsInvalid = true; + } + } + + $validLengths = $newValidLengths; + if ($validLengths === []) { + 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. @@ -1454,6 +1504,9 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK $isListOnlyIfKeysAreOptional = false; foreach ($newKeyTypes as $k2 => $newKeyType2) { + // A numeric-string key like "1" is an integer key at runtime, so + // normalize before deciding whether it continues the list. + $newKeyType2 = $newKeyType2->toArrayKey(); if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) { // We found a non-optional key that implies that the array is never a list. if (!in_array($k2, $newOptionalKeys, true)) { @@ -3034,12 +3087,21 @@ public function mergeWith(self $otherArray): self /** @var list $keyTypes */ $keyTypes = $keyTypes; + // Merging widens single-side keys to optional, so a sealed result may gain + // list realizations (e.g. `[]`) the naive `and` misses. `or`-ing in the + // shape's own list-ness lifts a `no`/`maybe` while keeping a genuine `yes`. + $naiveIsList = $this->isList->and($otherArray->isList); + $mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit(); + $isList = $mergedIsSealed + ? $naiveIsList->or(self::inferIsListFromShape($keyTypes, $optionalKeys)) + : $naiveIsList; + return $this->recreate( $keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, - $this->isList->and($otherArray->isList), + $isList, $resultUnsealed, ); } @@ -3066,7 +3128,16 @@ 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); + // Same recompute as mergeWith(), over `$this`'s keys only — this legacy + // path drops the other side's extra keys. + $naiveIsList = $this->isList->and($otherArray->isList); + $mergedIsSealed = $this->unsealed === null + || ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit()); + $isList = $mergedIsSealed + ? $naiveIsList->or(self::inferIsListFromShape($this->keyTypes, $optionalKeys)) + : $naiveIsList; + + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $this->unsealed); } /** @@ -3170,6 +3241,38 @@ 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; + } + + $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..9a97295f8f8 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,18 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt $this->degradeToGeneralArray = true; } + /** + * Record adding a key incompatible with list ordering. A required key breaks + * list-ness; an optional one only degrades Yes to Maybe (No stays No), since + * the array is still a list when the key is absent. + */ + 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..882b70b8ac0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14938.php @@ -0,0 +1,86 @@ + '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)); + } + +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 5c85f860264..2d65eed0b6c 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1639,6 +1639,115 @@ 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()); + + // 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()); + }); + } + + public function testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness(): void + { + BleedingEdgeToggle::withBleedingEdge(true, function (): void { + $never = new NeverType(true); + // array{0: string, '1'?: string} built directly with an un-normalized "1" + // string key. PHP stores "1" as the integer key 1, so the merged shape is + // a list — inferIsListFromShape() must normalize the key via toArrayKey(). + $x = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('1')], + [new StringType(), new StringType()], + [2], + [1], + null, + [$never, $never], + ); + $y = $this->buildShape([[0, new StringType(), false]]); + $this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe()); + }); + } + + public function testUnsetOffsetTreatsNumericStringKeyAsIntWhenRecomputingListness(): void + { + // list{'0': string, 1?: int} built directly with an un-normalized "0" string + // key. Unsetting the optional tail leaves list{'0': string}, still a list, so + // isListAfterUnset() must normalize the key via toArrayKey(). + $type = new ConstantArrayType( + [new ConstantStringType('0'), new ConstantIntegerType(1)], + [new StringType(), new IntegerType()], + [2], + [1], + TrinaryLogic::createYes(), + ); + + $unset = $type->unsetOffset(new ConstantIntegerType(1), true); + $this->assertInstanceOf(ConstantArrayType::class, $unset); + $this->assertSame(TrinaryLogic::createYes()->describe(), $unset->isList()->describe()); + } + public static function dataGetArraySize(): iterable { $cases = []; From 212bcf5f0703e35225ce35a76c97c142b2c0f261 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 7 Aug 2026 11:33:59 +0900 Subject: [PATCH 2/3] Use a named argument instead of restating the isList default positionally --- tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 2d65eed0b6c..5daf5a26a4e 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1722,8 +1722,7 @@ public function testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness( [new StringType(), new StringType()], [2], [1], - null, - [$never, $never], + unsealed: [$never, $never], ); $y = $this->buildShape([[0, new StringType(), false]]); $this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe()); From badee1315ddfa7a30d7396adf89946a8e5b9a2f0 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 7 Aug 2026 17:51:48 +0900 Subject: [PATCH 3/3] Turn the isList recompute tests into data providers and cover legacyMergeWith with a numeric-string key --- .../Type/Constant/ConstantArrayTypeTest.php | 137 ++++++++++++------ 1 file changed, 93 insertions(+), 44 deletions(-) diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 5daf5a26a4e..d1702e35860 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1655,74 +1655,123 @@ private function buildShape(array $items): ConstantArrayType return $array; } - public function testMakeListProjectsSealedShapeButKeepsUnsealedKeys(): void + /** + * @return iterable + */ + public static function dataMakeListProjectsSealedShapeButKeepsUnsealedKeys(): iterable { - // 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())); - }); + // Sealed (bleeding edge): key 2 can never appear in a list, so makeList() + // drops it and projects to the prefix. + yield 'sealed shape drops the gap key' => [true, 'array{int}']; - // Unsealed: extras may fill the gap, so key 2 is reachable and every key is kept. - BleedingEdgeToggle::withBleedingEdge(false, function (): void { + // Unsealed: extras may fill the gap, so key 2 is reachable and every key + // is kept. + yield 'unsealed shape keeps the gap key' => [false, 'array{0: int, 2?: string}']; + } + + #[DataProvider('dataMakeListProjectsSealedShapeButKeepsUnsealedKeys')] + public function testMakeListProjectsSealedShapeButKeepsUnsealedKeys(bool $bleedingEdge, string $expectedList): void + { + // A shape with an optional key past the gap at 1 (2? here). + BleedingEdgeToggle::withBleedingEdge($bleedingEdge, function () use ($expectedList): 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())); + $this->assertSame($expectedList, $array->makeList()->describe(VerbosityLevel::precise())); }); } - public function testMergeWithRecomputesListnessOfSealedShape(): void + /** + * @return iterable, list, TrinaryLogic}> + */ + public static function dataMergeWithRecomputesListnessOfSealedShape(): iterable { - // 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()); + // Bleeding edge (shapes carry real unsealed markers) exercises the main + // mergeWith() path; without it (unsealed === null) legacyMergeWith() runs, + // which recomputes over $this's keys only. + + // Two pure lists merge into a list, even though the merged shape read with + // independent optional keys would over-approximate to maybe. + yield 'two lists merge into a list' => [ + true, + [[0, new IntegerType(), false], [1, new StringType(), true]], + [[0, new IntegerType(), false], [1, new StringType(), false], [2, new IntegerType(), true]], + TrinaryLogic::createYes(), + ]; - // 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()); - }); + // A mandatory non-list key that becomes optional through merging turns the + // naive `no` into `maybe` (the merged shape now admits list realizations). + yield 'mandatory string key going optional lifts no to maybe' => [ + true, + [[0, new IntegerType(), false], ['a', new StringType(), false]], + [[0, new IntegerType(), false]], + TrinaryLogic::createMaybe(), + ]; - // 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()); + yield 'legacy: keys widened to a trailing optional stay a list' => [ + false, + [[0, new IntegerType(), false], [1, new StringType(), false], [2, new StringType(), false]], + [[0, new IntegerType(), false]], + TrinaryLogic::createYes(), + ]; + + yield 'legacy: mandatory string key going optional lifts no to maybe' => [ + false, + [[0, new IntegerType(), false], ['a', new StringType(), false]], + [[0, new IntegerType(), false]], + TrinaryLogic::createMaybe(), + ]; + + // Legacy merge keeps only $this's keys, so a naive `maybe` (the other side + // alone is `maybe` — it has a gap key at 5) sharpens back to `yes`: the + // surviving keys form a list once the key absent from the other side is + // widened to a trailing optional. + yield 'legacy: naive maybe sharpens back to yes' => [ + false, + [[0, new IntegerType(), false], [1, new StringType(), false]], + [[0, new IntegerType(), false], [5, new StringType(), true]], + TrinaryLogic::createYes(), + ]; + } - $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()); + /** + * @param list $left + * @param list $right + */ + #[DataProvider('dataMergeWithRecomputesListnessOfSealedShape')] + public function testMergeWithRecomputesListnessOfSealedShape(bool $bleedingEdge, array $left, array $right, TrinaryLogic $expectedIsList): void + { + BleedingEdgeToggle::withBleedingEdge($bleedingEdge, function () use ($left, $right, $expectedIsList): void { + $merged = $this->buildShape($left)->mergeWith($this->buildShape($right)); + $this->assertSame($expectedIsList->describe(), $merged->isList()->describe()); }); } - public function testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness(): void + /** + * @return iterable + */ + public static function dataMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness(): iterable + { + yield 'mergeWith (bleeding edge)' => [true]; + yield 'legacyMergeWith' => [false]; + } + + #[DataProvider('dataMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness')] + public function testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness(bool $bleedingEdge): void { - BleedingEdgeToggle::withBleedingEdge(true, function (): void { + BleedingEdgeToggle::withBleedingEdge($bleedingEdge, function () use ($bleedingEdge): void { $never = new NeverType(true); // array{0: string, '1'?: string} built directly with an un-normalized "1" // string key. PHP stores "1" as the integer key 1, so the merged shape is // a list — inferIsListFromShape() must normalize the key via toArrayKey(). + // With bleeding edge the shape is sealed by an explicit-never unsealed + // marker and merges through mergeWith(); without it, unsealed === null + // routes through legacyMergeWith() — the recompute's other call site. $x = new ConstantArrayType( [new ConstantIntegerType(0), new ConstantStringType('1')], [new StringType(), new StringType()], [2], [1], - unsealed: [$never, $never], + unsealed: $bleedingEdge ? [$never, $never] : null, ); $y = $this->buildShape([[0, new StringType(), false]]); $this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe());