From 4be2b562ae79842b7c9f7bf4ca7a47c3be937e23 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Wed, 29 Apr 2026 14:26:56 +0200 Subject: [PATCH 1/4] Fix oversized-array self-rejection in `optimizeConstantArrays` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oversized-array generalization in `TypeCombinator` could produce a type that was not a super-type of the variants it was derived from, causing `generator.valueType` (and similar) false positives where a closure's inferred Generator value type rejected the very yields it was synthesised from. Two distinct issues both produced contradictory `*&oversized-array` intersections or asymmetric shapes that `processArrayTypes` failed to unify cleanly. 1. `processArrayTypes` (`~line 962`) applied non-empty/oversized accessory types to reduced array results that were already known empty (`isIterableAtLeastOnce()->no()`), producing `array{}&oversized-array`. Now filter those accessories out for empty results. 2. `optimizeConstantArrays`'s stage 2 inner traverser (`~line 1071`), when walking a value position whose type was a `UnionType` containing a constant array, fell through via the outer `$traverse` instead of `$innerTraverse`. This re-entered the outer callback's full generalization branch (`array&accessories`) for sealed shapes reached through a union, while sealed shapes reached directly were only wrapped (`array{...}&oversized-array`). Mixing those two shapes left `processArrayTypes` with a union it could not unify cleanly. Also skip wrapping empty constant arrays at the inner level — that produces the same contradictory `array{}&oversized-array`. The regression was introduced in 2.1.52 by commit `2f66c45222` ("Preserve constant array when assigning a union of scalars"), which is itself a correct precision improvement; it exposed both latent bugs in `TypeCombinator` downstream. Authored by Claude (Opus 4.7) under the user's review. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/TypeCombinator.php | 34 ++++++- .../Rules/Generators/YieldTypeRuleTest.php | 10 ++ .../bug-yield-oversized-self-rejection.php | 95 +++++++++++++++++++ 3 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8181f93fe0d..0ef14508bf2 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -960,7 +960,18 @@ private static function processArrayTypes(array $arrayTypes): array $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true)); foreach ($reducedArrayTypes as $idx => $reducedArray) { - $reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$accessoryTypes); + $applied = $accessoryTypes; + if ($reducedArray->isIterableAtLeastOnce()->no()) { + // Empty arrays cannot satisfy non-empty / oversized constraints — + // applying those accessories would produce a contradictory intersection + // (e.g. `array{}&oversized-array`) that rejects the very value it + // represents, breaking the super-type contract of the union. + $applied = array_values(array_filter( + $applied, + static fn (Type $t): bool => !($t instanceof OversizedArrayType) && !($t instanceof NonEmptyArrayType), + )); + } + $reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied); } return $reducedArrayTypes; } @@ -1057,7 +1068,24 @@ 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 { + // Inner traversal of the value position. Two subtleties, both + // of which produced types that failed to be super-types of + // their contributors: + // - Empty constant arrays must be left alone; wrapping them + // builds a contradictory `array{}&oversized-array`. + // - Fall through via `$innerTraverse`, not the outer + // `$traverse`. The outer callback fully generalizes a + // sealed `ConstantArrayType` into `array&...`, + // which is correct at the top level but wrong inside a + // value position: it would treat a sealed `array{a: 1}` + // reached via `array{}|array{a: 1}` differently from one + // reached directly, leaving `processArrayTypes` with a + // mix of shapes it cannot unify cleanly. + $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 +1094,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/Rules/Generators/YieldTypeRuleTest.php b/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php index 6c6817eb8b7..f0da03a4164 100644 --- a/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php +++ b/tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php @@ -82,4 +82,14 @@ public function testBug7484(): void ]); } + public function testBugYieldOversizedSelfRejection(): void + { + // Regression: PHPStan inferred the closure's Generator value type from + // its yields, then rejected each yield against that inferred type. The + // oversized-array generalization in TypeCombinator::optimizeConstantArrays + // produced a constraint that was not a super-type of the variants it was + // derived from. Each yield is well-typed; no error must be reported. + $this->analyse([__DIR__ . '/data/bug-yield-oversized-self-rejection.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php b/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php new file mode 100644 index 00000000000..704c4cfe381 --- /dev/null +++ b/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php @@ -0,0 +1,95 @@ + $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 '1' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04']], + ]; + yield '2' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00'], ['14:00', '18:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04']], + ]; + yield '3' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['00:00', '00:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04']], + ]; + yield '4' => [ + 'kind' => $kind, + 'entries' => [[$key => [1, '2022-08-04', [['22:00', '04:00']]]]], + 'lookup' => [], + 'targets' => [[1, '2022-08-04/2022-08-05']], + ]; + yield '5' => [ + '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 '6' => [ + '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 '7' => [ + '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 '8' => [ + '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 '9' => [ + '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, + ]; +}); From 9fa28bf14f26b075755b47369f11127a27627b69 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Wed, 29 Apr 2026 14:51:16 +0200 Subject: [PATCH 2/4] Import `array_filter` via `use function` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Satisfies SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly — follow-up to the previous commit which added an `array_filter` call without a matching `use function` statement. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/TypeCombinator.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 0ef14508bf2..ea5c5a1e259 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; From 07e49568f8c86a3ebb2054d54a1f7f5bef40ea43 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Wed, 29 Apr 2026 15:00:00 +0200 Subject: [PATCH 3/4] Adapt test to avoid numeric-keys. --- .../bug-yield-oversized-self-rejection.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php b/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php index 704c4cfe381..cbe64065d6d 100644 --- a/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php +++ b/tests/PHPStan/Rules/Generators/data/bug-yield-oversized-self-rejection.php @@ -21,37 +21,37 @@ function bridge(callable $fn): \Generator bridge(static function (string $kind): iterable { $key = 'b' === $kind ? 'x' : 'y'; - yield '1' => [ + yield 'one' => [ 'kind' => $kind, 'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00']]]]], 'lookup' => [], 'targets' => [[1, '2022-08-04']], ]; - yield '2' => [ + 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 '3' => [ + yield 'three' => [ 'kind' => $kind, 'entries' => [[$key => [1, '2022-08-04', [['00:00', '00:00']]]]], 'lookup' => [], 'targets' => [[1, '2022-08-04']], ]; - yield '4' => [ + yield 'four' => [ 'kind' => $kind, 'entries' => [[$key => [1, '2022-08-04', [['22:00', '04:00']]]]], 'lookup' => [], 'targets' => [[1, '2022-08-04/2022-08-05']], ]; - yield '5' => [ + 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 '6' => [ + yield 'six' => [ 'kind' => $kind, 'entries' => [ [$key => [1, '2022-08-04', [['08:00', '12:00']]]], @@ -60,7 +60,7 @@ function bridge(callable $fn): \Generator 'lookup' => [], 'targets' => [[1, '2022-08-04'], [1, '2022-08-10']], ]; - yield '7' => [ + yield 'seven' => [ 'kind' => $kind, 'entries' => [ [$key => [1, '2022-08-04', [['08:00', '12:00']]]], @@ -70,7 +70,7 @@ function bridge(callable $fn): \Generator 'lookup' => [], 'targets' => [[1, '2022-08-04/2022-08-06']], ]; - yield '8' => [ + yield 'eight' => [ 'kind' => $kind, 'entries' => [ [$key => [1, '2022-08-04', [['08:00', '12:00']]]], @@ -82,7 +82,7 @@ function bridge(callable $fn): \Generator 'lookup' => [], 'targets' => [[1, '2022-08-04'], [2, '2022-08-05/2022-08-06'], [3, '2022-08-06'], [3, '2022-08-10']], ]; - yield '9' => [ + yield 'nine' => [ 'kind' => $kind, 'entries' => [ [$key => [1, '2022-08-04', [['08:00', '12:00']]]], From 4e94226afe4385bfe5d3f5eb81e22169b0816677 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Thu, 30 Apr 2026 13:04:26 +0200 Subject: [PATCH 4/4] Generalize empty-array accessory filter via `accepts()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vincent's review on phpstan/phpstan-src#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 8d87c671c, "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) --- src/Type/TypeCombinator.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index ea5c5a1e259..6254a6de9b3 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -960,16 +960,22 @@ private static function processArrayTypes(array $arrayTypes): array } $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true)); + $emptyArrayType = null; foreach ($reducedArrayTypes as $idx => $reducedArray) { $applied = $accessoryTypes; if ($reducedArray->isIterableAtLeastOnce()->no()) { - // Empty arrays cannot satisfy non-empty / oversized constraints — - // applying those accessories would produce a contradictory intersection - // (e.g. `array{}&oversized-array`) that rejects the very value it - // represents, breaking the super-type contract of the union. + // Filter accessories that reject empty arrays — applying them + // would build a contradictory intersection (e.g. + // `array{}&oversized-array`, `array{}&hasOffset('foo')`) that + // rejects the very value it represents, breaking the super-type + // contract of the union. Today only `OversizedArrayType` can + // leak here via the partial-presence special case in + // `processArrayAccessoryTypes`; the predicate is general so + // that future accessories don't reintroduce the bug. + $emptyArrayType ??= new ConstantArrayType([], []); $applied = array_values(array_filter( $applied, - static fn (Type $t): bool => !($t instanceof OversizedArrayType) && !($t instanceof NonEmptyArrayType), + static fn (Type $t): bool => !$t->accepts($emptyArrayType, true)->no(), )); } $reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied);