Skip to content

Commit 4be2b56

Browse files
gnutixclaude
andcommitted
Fix oversized-array self-rejection in optimizeConstantArrays
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<intKey, V>&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) <noreply@anthropic.com>
1 parent 2f627eb commit 4be2b56

3 files changed

Lines changed: 136 additions & 3 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -960,7 +960,18 @@ private static function processArrayTypes(array $arrayTypes): array
960960

961961
$reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true));
962962
foreach ($reducedArrayTypes as $idx => $reducedArray) {
963-
$reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$accessoryTypes);
963+
$applied = $accessoryTypes;
964+
if ($reducedArray->isIterableAtLeastOnce()->no()) {
965+
// Empty arrays cannot satisfy non-empty / oversized constraints —
966+
// applying those accessories would produce a contradictory intersection
967+
// (e.g. `array{}&oversized-array`) that rejects the very value it
968+
// represents, breaking the super-type contract of the union.
969+
$applied = array_values(array_filter(
970+
$applied,
971+
static fn (Type $t): bool => !($t instanceof OversizedArrayType) && !($t instanceof NonEmptyArrayType),
972+
));
973+
}
974+
$reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied);
964975
}
965976
return $reducedArrayTypes;
966977
}
@@ -1057,7 +1068,24 @@ private static function optimizeConstantArrays(array $types): array
10571068
$generalizedKeyType = $innerKeyType->generalize(GeneralizePrecision::moreSpecific());
10581069
$keyTypes[$generalizedKeyType->describe(VerbosityLevel::precise())] = $generalizedKeyType;
10591070

1060-
$generalizedValueType = TypeTraverser::map($innerValueTypes[$i], static function (Type $type) use ($traverse): Type {
1071+
// Inner traversal of the value position. Two subtleties, both
1072+
// of which produced types that failed to be super-types of
1073+
// their contributors:
1074+
// - Empty constant arrays must be left alone; wrapping them
1075+
// builds a contradictory `array{}&oversized-array`.
1076+
// - Fall through via `$innerTraverse`, not the outer
1077+
// `$traverse`. The outer callback fully generalizes a
1078+
// sealed `ConstantArrayType` into `array<intKey, V>&...`,
1079+
// which is correct at the top level but wrong inside a
1080+
// value position: it would treat a sealed `array{a: 1}`
1081+
// reached via `array{}|array{a: 1}` differently from one
1082+
// reached directly, leaving `processArrayTypes` with a
1083+
// mix of shapes it cannot unify cleanly.
1084+
$generalizedValueType = TypeTraverser::map($innerValueTypes[$i], static function (Type $type, callable $innerTraverse): Type {
1085+
if ($type instanceof ConstantArrayType && $type->isIterableAtLeastOnce()->no()) {
1086+
return $type;
1087+
}
1088+
10611089
if ($type instanceof ArrayType || $type instanceof ConstantArrayType) {
10621090
return new IntersectionType([$type, new OversizedArrayType()]);
10631091
}
@@ -1066,7 +1094,7 @@ private static function optimizeConstantArrays(array $types): array
10661094
return $type->generalize(GeneralizePrecision::moreSpecific());
10671095
}
10681096

1069-
return $traverse($type);
1097+
return $innerTraverse($type);
10701098
});
10711099
$valueTypes[$generalizedValueType->describe(VerbosityLevel::precise())] = $generalizedValueType;
10721100
}

tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,14 @@ public function testBug7484(): void
8282
]);
8383
}
8484

85+
public function testBugYieldOversizedSelfRejection(): void
86+
{
87+
// Regression: PHPStan inferred the closure's Generator value type from
88+
// its yields, then rejected each yield against that inferred type. The
89+
// oversized-array generalization in TypeCombinator::optimizeConstantArrays
90+
// produced a constraint that was not a super-type of the variants it was
91+
// derived from. Each yield is well-typed; no error must be reported.
92+
$this->analyse([__DIR__ . '/data/bug-yield-oversized-self-rejection.php'], []);
93+
}
94+
8595
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace BugYieldOversizedSelfRejection;
4+
5+
/**
6+
* Reproducer for a regression where `optimizeConstantArrays` produced a
7+
* Generator value type that did not accept the very yields it was inferred
8+
* from. Each yield is well-typed; the closure's inferred Generator value
9+
* type must therefore be a super-type of every value it actually yields.
10+
*
11+
* @param callable(string): iterable<string, mixed> $fn
12+
* @return \Generator<string, mixed>
13+
*/
14+
function bridge(callable $fn): \Generator
15+
{
16+
foreach (['a', 'b'] as $kind) {
17+
yield from $fn($kind);
18+
}
19+
}
20+
21+
bridge(static function (string $kind): iterable {
22+
$key = 'b' === $kind ? 'x' : 'y';
23+
24+
yield '1' => [
25+
'kind' => $kind,
26+
'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00']]]]],
27+
'lookup' => [],
28+
'targets' => [[1, '2022-08-04']],
29+
];
30+
yield '2' => [
31+
'kind' => $kind,
32+
'entries' => [[$key => [1, '2022-08-04', [['08:00', '12:00'], ['14:00', '18:00']]]]],
33+
'lookup' => [],
34+
'targets' => [[1, '2022-08-04']],
35+
];
36+
yield '3' => [
37+
'kind' => $kind,
38+
'entries' => [[$key => [1, '2022-08-04', [['00:00', '00:00']]]]],
39+
'lookup' => [],
40+
'targets' => [[1, '2022-08-04']],
41+
];
42+
yield '4' => [
43+
'kind' => $kind,
44+
'entries' => [[$key => [1, '2022-08-04', [['22:00', '04:00']]]]],
45+
'lookup' => [],
46+
'targets' => [[1, '2022-08-04/2022-08-05']],
47+
];
48+
yield '5' => [
49+
'kind' => $kind,
50+
'entries' => [[$key => [1, '2022-08-04', [['16:00', '23:00']], 'lookupIds' => [42]]]],
51+
'lookup' => [42 => [1, '2022-08-05T00:05/2022-08-05T02:00']],
52+
'targets' => [[1, '2022-08-04/2022-08-05']],
53+
];
54+
yield '6' => [
55+
'kind' => $kind,
56+
'entries' => [
57+
[$key => [1, '2022-08-04', [['08:00', '12:00']]]],
58+
[$key => [1, '2022-08-10', [['08:00', '12:00']]]],
59+
],
60+
'lookup' => [],
61+
'targets' => [[1, '2022-08-04'], [1, '2022-08-10']],
62+
];
63+
yield '7' => [
64+
'kind' => $kind,
65+
'entries' => [
66+
[$key => [1, '2022-08-04', [['08:00', '12:00']]]],
67+
[$key => [1, '2022-08-05', [['08:00', '12:00']]]],
68+
[$key => [1, '2022-08-06', [['08:00', '12:00']]]],
69+
],
70+
'lookup' => [],
71+
'targets' => [[1, '2022-08-04/2022-08-06']],
72+
];
73+
yield '8' => [
74+
'kind' => $kind,
75+
'entries' => [
76+
[$key => [1, '2022-08-04', [['08:00', '12:00']]]],
77+
[$key => [2, '2022-08-05', [['08:00', '12:00']]]],
78+
[$key => [2, '2022-08-06', [['08:00', '12:00']]]],
79+
[$key => [3, '2022-08-06', [['08:00', '12:00']]]],
80+
[$key => [3, '2022-08-10', [['08:00', '12:00']]]],
81+
],
82+
'lookup' => [],
83+
'targets' => [[1, '2022-08-04'], [2, '2022-08-05/2022-08-06'], [3, '2022-08-06'], [3, '2022-08-10']],
84+
];
85+
yield '9' => [
86+
'kind' => $kind,
87+
'entries' => [
88+
[$key => [1, '2022-08-04', [['08:00', '12:00']]]],
89+
[$key => [1, '2022-08-05', [['08:00', '12:00']]]],
90+
],
91+
'lookup' => [],
92+
'targets' => [],
93+
'flag' => false,
94+
];
95+
});

0 commit comments

Comments
 (0)