diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8181f93fe0d..6254a6de9b3 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,25 @@ 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()) { + // 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->accepts($emptyArrayType, true)->no(), + )); + } + $reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied); } return $reducedArrayTypes; } @@ -1057,7 +1075,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 +1101,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..cbe64065d6d --- /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 '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, + ]; +});