Skip to content

Commit 11c241d

Browse files
committed
More SA fixes
1 parent b45a41b commit 11c241d

5 files changed

Lines changed: 97 additions & 26 deletions

File tree

src/Type/Constant/ConstantArrayType.php

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2178,40 +2178,43 @@ public function isKeysSupersetOf(self $otherArray): bool
21782178
return $this->legacyIsKeysSupersetOf($otherArray);
21792179
}
21802180

2181-
$keyIndexMap = $this->getKeyIndexMap();
2182-
$otherKeyIndexMap = $otherArray->getKeyIndexMap();
2183-
2184-
// Disjoint values at a common key prevent a lossless merge
2185-
$hasCommon = false;
2186-
foreach ($otherKeyIndexMap as $keyValue => $j) {
2187-
if (!array_key_exists($keyValue, $keyIndexMap)) {
2188-
continue;
2189-
}
2190-
$i = $keyIndexMap[$keyValue];
2191-
$valueType = $this->valueTypes[$i];
2192-
$otherValueType = $otherArray->valueTypes[$j];
2193-
if ($valueType->isSuperTypeOf($otherValueType)->no() && $otherValueType->isSuperTypeOf($valueType)->no()) {
2194-
return false;
2195-
}
2196-
$hasCommon = true;
2197-
}
2198-
21992181
[$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
22002182
[$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed;
22012183
$thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit());
22022184
$otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit());
22032185

2204-
if ($hasCommon) {
2186+
$otherHasRequiredKeys = false;
2187+
foreach ($otherArray->keyTypes as $j => $keyType) {
2188+
if ($otherArray->isOptionalKey($j)) {
2189+
continue;
2190+
}
2191+
$otherHasRequiredKeys = true;
2192+
break;
2193+
}
2194+
2195+
// Sealed empty $other (no keys, no extras): absorbing it is lossless iff $this
2196+
// already accepts []. i.e., all of $this's known keys are optional. Otherwise
2197+
// merge would add [] as a new instance.
2198+
if (!$otherHasRequiredKeys && !$otherHasExtras && count($otherArray->keyTypes) === 0) {
2199+
foreach ($this->keyTypes as $i => $keyType) {
2200+
if (!$this->isOptionalKey($i)) {
2201+
return false;
2202+
}
2203+
}
22052204
return true;
22062205
}
22072206

2207+
// With real unsealed extras on both sides that can absorb each other's
2208+
// required keys, merging is acceptable regardless of which keys overlap.
22082209
if ($thisHasExtras && $otherHasExtras) {
22092210
return true;
22102211
}
22112212

2212-
// Mixed or both sealed, no common keys — only merge if one side's extras can
2213-
// absorb the other side's required keys (preserves tagged-union otherwise).
2213+
// Asymmetric extras: one side has real extras that can absorb the other's keys.
22142214
if ($thisHasExtras) {
2215+
if ($this->legacyIsKeysSupersetOf($otherArray)) {
2216+
return true;
2217+
}
22152218
foreach ($otherArray->keyTypes as $j => $keyType) {
22162219
if ($otherArray->isOptionalKey($j)) {
22172220
continue;
@@ -2227,6 +2230,9 @@ public function isKeysSupersetOf(self $otherArray): bool
22272230
}
22282231

22292232
if ($otherHasExtras) {
2233+
if ($this->legacyIsKeysSupersetOf($otherArray)) {
2234+
return true;
2235+
}
22302236
foreach ($this->keyTypes as $i => $keyType) {
22312237
if ($this->isOptionalKey($i)) {
22322238
continue;
@@ -2241,7 +2247,8 @@ public function isKeysSupersetOf(self $otherArray): bool
22412247
return true;
22422248
}
22432249

2244-
return false;
2250+
// Both sealed: fall back to the legacy key/value shape check.
2251+
return $this->legacyIsKeysSupersetOf($otherArray);
22452252
}
22462253

22472254
private function legacyIsKeysSupersetOf(self $otherArray): bool
@@ -2352,7 +2359,7 @@ public function mergeWith(self $otherArray): self
23522359
$j = $otherKeyIndexMap[$keyValue];
23532360
$otherValueType = $otherArray->valueTypes[$j];
23542361
$mergedValue = TypeCombinator::union($valueType, $otherValueType);
2355-
$optional = $this->isOptionalKey($i) && $otherArray->isOptionalKey($j);
2362+
$optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j);
23562363

23572364
$keyTypes[] = $keyType;
23582365
$valueTypes[] = $mergedValue;

src/Type/TypeCombinator.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,14 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
11561156
}
11571157

11581158
if ($emptyArray !== null) {
1159-
$newArrays[] = $emptyArray;
1159+
if ($preserveTaggedUnions && $emptyArray instanceof ConstantArrayType) {
1160+
// Let the empty array participate in merging — the passes below will absorb
1161+
// it into any array that already accepts [] (all-optional keys, compatible
1162+
// unsealed extras). If no such array exists, it remains as-is in the result.
1163+
$arraysToProcess[] = $emptyArray;
1164+
} else {
1165+
$newArrays[] = $emptyArray;
1166+
}
11601167
}
11611168

11621169
$arraysToProcessPerKey = [];
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace ArrayAppendCount;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function multipleConditionalAppends(int $a, int $b, int $c): void
8+
{
9+
$types = ['base'];
10+
if ($a > 0) {
11+
$types[] = 'x';
12+
} elseif ($a < 0) {
13+
$types[] = 'y';
14+
}
15+
if ($b > 0) {
16+
$types[] = 'z';
17+
}
18+
if ($c === 1) {
19+
$types[] = 'p';
20+
} elseif ($c === 2) {
21+
$types[] = 'q';
22+
}
23+
24+
// $types could have 1 (just 'base'), or 2/3/4 depending on which
25+
// elseif arms fire. count should at least allow 1.
26+
assertType('int<1, 4>', count($types));
27+
28+
if (count($types) === 1) {
29+
// reachable: all three ifs miss — $types stays as ['base'].
30+
assertType("array{'base'}", $types);
31+
}
32+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace ConditionalArrayKeyExists;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/** @param array<string, mixed> $options */
8+
function apply(array $options): void
9+
{
10+
$range = [];
11+
if (isset($options['min_range'])) {
12+
$range['min'] = 1;
13+
}
14+
if (isset($options['max_range'])) {
15+
$range['max'] = 2;
16+
}
17+
18+
// $range can be {}, {min}, {max}, or {min, max}
19+
assertType('array{min?: 1, max?: 2}', $range);
20+
21+
if (array_key_exists('min', $range) || array_key_exists('max', $range)) {
22+
// reachable: either key could be set.
23+
assertType('non-empty-array{min?: 1, max?: 2}', $range);
24+
}
25+
}

tests/PHPStan/Type/TypeCombinatorTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3102,8 +3102,8 @@ public static function dataUnion(): iterable
31023102
'array{a: int, ...}',
31033103
'array{a: string, ...}',
31043104
],
3105-
UnionType::class,
3106-
'array{a: int, ...}|array{a: string, ...}',
3105+
ConstantArrayType::class,
3106+
'array{a: int|string, ...}',
31073107
];
31083108

31093109
yield [

0 commit comments

Comments
 (0)