Skip to content

Commit 3beaa09

Browse files
ondrejmirtesclaude
andcommitted
Generalize unsealed ConstantArrayType instead of returning it unchanged
`generalize()` early-returned `$this` when `count($keyTypes) === 0`, which is correct for actually empty sealed arrays but wrong for a CAT with no explicit keys but real unsealed extras (`array{...<int, 'foo'>}` — type-checked construction; the builder normally collapses this shape to `ArrayType`, but it can still appear through `recreate()` paths). The constant values inside the unsealed slot survived `generalize()` untouched. Two fixes, mirroring each other: 1. Only early-return for "no explicit keys *and* not unsealed". When the CAT carries real extras, proceed through the normal path so the unsealed key/value are generalized through `getIterableKeyType()` / `getIterableValueType()`. 2. Switch the `NonEmptyArrayType` gate from `keyTypesCount > optionalKeysCount` to `isIterableAtLeastOnce()->yes()`. The old gate was a proxy for "the array is definitely non-empty" that only looked at explicit keys; for "no explicit keys + real extras" it would incorrectly require the accessory. The `isIterableAtLeastOnce()` answer is what we actually want — and for sealed shapes it matches the previous gate exactly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ba6d89c commit 3beaa09

2 files changed

Lines changed: 21 additions & 3 deletions

File tree

src/Type/Constant/ConstantArrayType.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2250,7 +2250,8 @@ public function toFloat(): Type
22502250

22512251
public function generalize(GeneralizePrecision $precision): Type
22522252
{
2253-
if (count($this->keyTypes) === 0) {
2253+
// No explicit keys and no real extras — actually empty, return as-is.
2254+
if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) {
22542255
return $this;
22552256
}
22562257

@@ -2275,7 +2276,14 @@ public function generalize(GeneralizePrecision $precision): Type
22752276

22762277
$accessoryTypes[] = new HasOffsetValueType($keyType, $this->valueTypes[$i]->generalize($precision));
22772278
}
2278-
} elseif ($keyTypesCount > $optionalKeysCount) {
2279+
} elseif ($this->isIterableAtLeastOnce()->yes()) {
2280+
// Previously gated on `keyTypesCount > optionalKeysCount`,
2281+
// which mishandles "no explicit keys + real unsealed
2282+
// extras" (`isIterableAtLeastOnce()` answers `Maybe` —
2283+
// extras might be empty — and correctly skips
2284+
// `NonEmptyArrayType`). The new gate also covers the
2285+
// usual sealed-with-required-keys case, so behaviour for
2286+
// existing CAT shapes is unchanged.
22792287
$accessoryTypes[] = new NonEmptyArrayType();
22802288
}
22812289

tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1932,7 +1932,17 @@ public static function dataGeneralize(): iterable
19321932
yield 'unsealed only, lessSpecific' => [
19331933
new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]),
19341934
GeneralizePrecision::lessSpecific(),
1935-
"array{...<int, 'foo'>}",
1935+
// No explicit keys but real unsealed extras — generalize
1936+
// has to broaden the unsealed value (`'foo'` → `string`)
1937+
// and degrade to a plain `ArrayType`. The size is uncertain
1938+
// (zero-or-more extras), so no `NonEmptyArrayType`.
1939+
'array<int, string>',
1940+
];
1941+
1942+
yield 'unsealed only with non-falsy-string key, moreSpecific' => [
1943+
new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]),
1944+
GeneralizePrecision::moreSpecific(),
1945+
'array<int, literal-string&lowercase-string&non-falsy-string>',
19361946
];
19371947

19381948
yield 'unsealed with explicit key, lessSpecific' => [

0 commit comments

Comments
 (0)