Skip to content

Commit 4e94226

Browse files
gnutixclaude
andcommitted
Generalize empty-array accessory filter via accepts()
Vincent's review on #5568 raised that the explicit `OversizedArrayType` / `NonEmptyArrayType` filter is too narrow — the same `array{} & accessory` contradiction could in principle arise with any accessory whose `accepts(array{})` is `no` (e.g. `HasOffsetType`). Switch the predicate to `!$accessory->accepts(emptyArray, true)->no()` so future accessories don't reintroduce the bug. Notes from the audit: - `OversizedArrayType::isIterableAtLeastOnce()` returns `maybe` (relaxed by 8d87c67, "Solve 11703" — an oversized-tagged array built via conditional pushes can be empty), so the more obvious `$accessory->isIterableAtLeastOnce()->yes()` predicate would NOT catch oversized. The `accepts(array{})` predicate is the correct one because the bug surfaces via accepts-based supertype checks. - Today only `OversizedArrayType` actually leaks into the common accessory set via partial presence — `NonEmptyArrayType`, `HasOffsetType`, `HasOffsetValueType`, `AccessoryArrayListType` are added to the common set only when ALL inputs carry them, so no empty input can reach the intersection. The general predicate is forward-defensive, not currently load-bearing for the other accessories. - There is also an internal inconsistency in `OversizedArrayType` itself: `accepts()` still uses `isIterableAtLeastOnce()->yes()` while `isIterableAtLeastOnce()` returns `maybe`. That's tracked as a separate concern in phpstan/phpstan#14560 and is out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07e4956 commit 4e94226

1 file changed

Lines changed: 11 additions & 5 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -960,16 +960,22 @@ private static function processArrayTypes(array $arrayTypes): array
960960
}
961961

962962
$reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true));
963+
$emptyArrayType = null;
963964
foreach ($reducedArrayTypes as $idx => $reducedArray) {
964965
$applied = $accessoryTypes;
965966
if ($reducedArray->isIterableAtLeastOnce()->no()) {
966-
// Empty arrays cannot satisfy non-empty / oversized constraints —
967-
// applying those accessories would produce a contradictory intersection
968-
// (e.g. `array{}&oversized-array`) that rejects the very value it
969-
// represents, breaking the super-type contract of the union.
967+
// Filter accessories that reject empty arrays — applying them
968+
// would build a contradictory intersection (e.g.
969+
// `array{}&oversized-array`, `array{}&hasOffset('foo')`) that
970+
// rejects the very value it represents, breaking the super-type
971+
// contract of the union. Today only `OversizedArrayType` can
972+
// leak here via the partial-presence special case in
973+
// `processArrayAccessoryTypes`; the predicate is general so
974+
// that future accessories don't reintroduce the bug.
975+
$emptyArrayType ??= new ConstantArrayType([], []);
970976
$applied = array_values(array_filter(
971977
$applied,
972-
static fn (Type $t): bool => !($t instanceof OversizedArrayType) && !($t instanceof NonEmptyArrayType),
978+
static fn (Type $t): bool => !$t->accepts($emptyArrayType, true)->no(),
973979
));
974980
}
975981
$reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied);

0 commit comments

Comments
 (0)