From c2838edd196c0be23340ab01891ae216ecdb395b Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:43:45 +0000 Subject: [PATCH] Skip contradictory accessory types on empty arrays and use inner traverser in `optimizeConstantArrays` value generalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In `processArrayTypes`, filter out accessory types that reject empty arrays (e.g. `OversizedArrayType`, `NonEmptyArrayType`) before intersecting them with reduced arrays that are empty. Without this, `processArrayAccessoryTypes`'s special promotion of `OversizedArrayType` (when present on some but not all union members) produces contradictory intersections like `array{}&oversized-array`. - In `optimizeConstantArrays` stage 2, use `$innerTraverse` (the inner `TypeTraverser::map` callback) instead of `$traverse` (the outer one) when recursing into value positions. The outer callback fully generalizes a sealed `ConstantArrayType` into `array&...`, which is correct at the top level but wrong inside a value position — it treats nested shapes inconsistently depending on how they are reached. - Also skip wrapping empty constant arrays with `OversizedArrayType` in the inner traverser, for the same contradictory-intersection reason. --- src/Type/TypeCombinator.php | 20 ++++- tests/PHPStan/Analyser/nsrt/bug-14560.php | 51 +++++++++++ .../Rules/Generators/YieldTypeRuleTest.php | 5 ++ .../Rules/Generators/data/bug-14560.php | 90 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14560.php create mode 100644 tests/PHPStan/Rules/Generators/data/bug-14560.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8181f93fe0d..9fc54089cb4 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -27,6 +27,7 @@ use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateUnionType; use function array_fill; +use function array_filter; use function array_key_exists; use function array_key_first; use function array_merge; @@ -959,8 +960,17 @@ private static function processArrayTypes(array $arrayTypes): array } $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true)); + $emptyArrayType = null; foreach ($reducedArrayTypes as $idx => $reducedArray) { - $reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$accessoryTypes); + $applied = $accessoryTypes; + if ($reducedArray->isIterableAtLeastOnce()->no()) { + $emptyArrayType ??= new ConstantArrayType([], []); + $applied = array_values(array_filter( + $applied, + static fn (Type $t): bool => !$t->accepts($emptyArrayType, true)->no(), + )); + } + $reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied); } return $reducedArrayTypes; } @@ -1057,7 +1067,11 @@ private static function optimizeConstantArrays(array $types): array $generalizedKeyType = $innerKeyType->generalize(GeneralizePrecision::moreSpecific()); $keyTypes[$generalizedKeyType->describe(VerbosityLevel::precise())] = $generalizedKeyType; - $generalizedValueType = TypeTraverser::map($innerValueTypes[$i], static function (Type $type) use ($traverse): Type { + $generalizedValueType = TypeTraverser::map($innerValueTypes[$i], static function (Type $type, callable $innerTraverse): Type { + if ($type instanceof ConstantArrayType && $type->isIterableAtLeastOnce()->no()) { + return $type; + } + if ($type instanceof ArrayType || $type instanceof ConstantArrayType) { return new IntersectionType([$type, new OversizedArrayType()]); } @@ -1066,7 +1080,7 @@ private static function optimizeConstantArrays(array $types): array return $type->generalize(GeneralizePrecision::moreSpecific()); } - return $traverse($type); + return $innerTraverse($type); }); $valueTypes[$generalizedValueType->describe(VerbosityLevel::precise())] = $generalizedValueType; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14560.php b/tests/PHPStan/Analyser/nsrt/bug-14560.php new file mode 100644 index 00000000000..1260190358e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14560.php @@ -0,0 +1,51 @@ + 'a', 'data' => [['x' => 1]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'b', 'data' => [['x' => 2]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'c', 'data' => [['x' => 3]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'd', 'data' => [['x' => 4]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'e', 'data' => [['x' => 5]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'f', 'data' => [['x' => 6]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'g', 'data' => [['x' => 7]], 'extra' => []]; + } + if (rand()) { + $items[] = ['kind' => 'h', 'data' => [['x' => 8]], 'extra' => []]; + } + + if ($items === []) { + return; + } + + foreach ($items as $item) { + // The type of $item['extra'] must accept array{} — the empty array that + // every branch actually writes. Before the fix, optimizeConstantArrays + // would tag it with OversizedArrayType, producing array{}&oversized-array + // which contradicts itself (empty but oversized). + assertType('array{}', $item['extra']); + } +} diff --git a/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php b/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php index 6c6817eb8b7..28b78eb9326 100644 --- a/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php +++ b/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php @@ -82,4 +82,9 @@ public function testBug7484(): void ]); } + public function testBug14560(): void + { + $this->analyse([__DIR__ . '/data/bug-14560.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Generators/data/bug-14560.php b/tests/PHPStan/Rules/Generators/data/bug-14560.php new file mode 100644 index 00000000000..b66a6ab954f --- /dev/null +++ b/tests/PHPStan/Rules/Generators/data/bug-14560.php @@ -0,0 +1,90 @@ + $fn + * @return \Generator + */ +function bridge(callable $fn): \Generator +{ + foreach (['a', 'b'] as $kind) { + yield from $fn($kind); + } +} + +bridge(static function (string $kind): iterable { + $key = 'b' === $kind ? 'x' : 'y'; + + yield 'one' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04']], + ]; + yield 'two' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00'], ['14:00', '18:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04']], + ]; + yield 'three' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['00:00', '00:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04']], + ]; + yield 'four' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['22:00', '04:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04/2022-08-05']], + ]; + yield 'five' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['16:00', '23:00']], 'lookupIds' => [42]]]], + 'lookup' => [42 => [1, '2022-08-05T00:05/2022-08-05T02:00']], + 'targets' => [[1, '2022-08-04/2022-08-05']], + ]; + yield 'six' => [ + 'kind' => $kind, + 'entries' => [ + [$key => [1, '2022-08-04', [['08:00', '12:00']]]], + [$key => [1, '2022-08-10', [['08:00', '12:00']]]], + ], + 'lookup' => [], + 'targets' => [[1, '2022-08-04'], [1, '2022-08-10']], + ]; + yield 'seven' => [ + 'kind' => $kind, + 'entries' => [ + [$key => [1, '2022-08-04', [['08:00', '12:00']]]], + [$key => [1, '2022-08-05', [['08:00', '12:00']]]], + [$key => [1, '2022-08-06', [['08:00', '12:00']]]], + ], + 'lookup' => [], + 'targets' => [[1, '2022-08-04/2022-08-06']], + ]; + yield 'eight' => [ + 'kind' => $kind, + 'entries' => [ + [$key => [1, '2022-08-04', [['08:00', '12:00']]]], + [$key => [2, '2022-08-05', [['08:00', '12:00']]]], + [$key => [2, '2022-08-06', [['08:00', '12:00']]]], + [$key => [3, '2022-08-06', [['08:00', '12:00']]]], + [$key => [3, '2022-08-10', [['08:00', '12:00']]]], + ], + 'lookup' => [], + 'targets' => [[1, '2022-08-04'], [2, '2022-08-05/2022-08-06'], [3, '2022-08-06'], [3, '2022-08-10']], + ]; + yield 'nine' => [ + 'kind' => $kind, + 'entries' => [ + [$key => [1, '2022-08-04', [['08:00', '12:00']]]], + [$key => [1, '2022-08-05', [['08:00', '12:00']]]], + ], + 'lookup' => [], + 'targets' => [], + 'flag' => false, + ]; +});