Skip to content

Commit 9743346

Browse files
ondrejmirtesgnutixclaude
committed
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: Dorian Villet <dorian.villet@tipee.ch> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28f6ffe commit 9743346

4 files changed

Lines changed: 225 additions & 3 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use PHPStan\Type\Generic\TemplateTypeFactory;
2828
use PHPStan\Type\Generic\TemplateUnionType;
2929
use function array_fill;
30+
use function array_filter;
3031
use function array_key_exists;
3132
use function array_key_first;
3233
use function array_merge;
@@ -960,7 +961,18 @@ private static function processArrayTypes(array $arrayTypes): array
960961

961962
$reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true));
962963
foreach ($reducedArrayTypes as $idx => $reducedArray) {
963-
$reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$accessoryTypes);
964+
$applied = $accessoryTypes;
965+
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.
970+
$applied = array_values(array_filter(
971+
$applied,
972+
static fn (Type $t): bool => !($t instanceof OversizedArrayType) && !($t instanceof NonEmptyArrayType),
973+
));
974+
}
975+
$reducedArrayTypes[$idx] = self::intersect($reducedArray, ...$applied);
964976
}
965977
return $reducedArrayTypes;
966978
}
@@ -1057,7 +1069,24 @@ private static function optimizeConstantArrays(array $types): array
10571069
$generalizedKeyType = $innerKeyType->generalize(GeneralizePrecision::moreSpecific());
10581070
$keyTypes[$generalizedKeyType->describe(VerbosityLevel::precise())] = $generalizedKeyType;
10591071

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

1069-
return $traverse($type);
1098+
return $innerTraverse($type);
10701099
});
10711100
$valueTypes[$generalizedValueType->describe(VerbosityLevel::precise())] = $generalizedValueType;
10721101
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace BugYieldOversizedSelfRejectionNsrt;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function build(string $eventClass): array
8+
{
9+
$element = 'Deleted' === $eventClass ? 'old' : 'new';
10+
11+
if (rand()) {
12+
$r = [
13+
'eventClass' => $eventClass,
14+
'changes' => [[$element => [1, '2022-08-04', [['08:00', '12:00']]]]],
15+
'matchedTimechecks' => [],
16+
'invalidates' => [[1, '2022-08-04']],
17+
];
18+
} elseif (rand()) {
19+
$r = [
20+
'eventClass' => $eventClass,
21+
'changes' => [[$element => [1, '2022-08-04', [['08:00', '12:00'], ['14:00', '18:00']]]]],
22+
'matchedTimechecks' => [],
23+
'invalidates' => [[1, '2022-08-04']],
24+
];
25+
} elseif (rand()) {
26+
$r = [
27+
'eventClass' => $eventClass,
28+
'changes' => [[$element => [1, '2022-08-04', [['00:00', '00:00']]]]],
29+
'matchedTimechecks' => [],
30+
'invalidates' => [[1, '2022-08-04']],
31+
];
32+
} elseif (rand()) {
33+
$r = [
34+
'eventClass' => $eventClass,
35+
'changes' => [[$element => [1, '2022-08-04', [['22:00', '04:00']]]]],
36+
'matchedTimechecks' => [],
37+
'invalidates' => [[1, '2022-08-04/2022-08-05']],
38+
];
39+
} elseif (rand()) {
40+
$r = [
41+
'eventClass' => $eventClass,
42+
'changes' => [[$element => [1, '2022-08-04', [['16:00', '23:00']], 'matchedTimecheckIds' => [42]]]],
43+
'matchedTimechecks' => [42 => [1, '2022-08-05T00:05/2022-08-05T02:00']],
44+
'invalidates' => [[1, '2022-08-04/2022-08-05']],
45+
];
46+
} elseif (rand()) {
47+
$r = [
48+
'eventClass' => $eventClass,
49+
'changes' => [
50+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
51+
[$element => [1, '2022-08-10', [['08:00', '12:00']]]],
52+
],
53+
'matchedTimechecks' => [],
54+
'invalidates' => [[1, '2022-08-04'], [1, '2022-08-10']],
55+
];
56+
} elseif (rand()) {
57+
$r = [
58+
'eventClass' => $eventClass,
59+
'changes' => [
60+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
61+
[$element => [1, '2022-08-05', [['08:00', '12:00']]]],
62+
[$element => [1, '2022-08-06', [['08:00', '12:00']]]],
63+
],
64+
'matchedTimechecks' => [],
65+
'invalidates' => [[1, '2022-08-04/2022-08-06']],
66+
];
67+
} elseif (rand()) {
68+
$r = [
69+
'eventClass' => $eventClass,
70+
'changes' => [
71+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
72+
[$element => [2, '2022-08-05', [['08:00', '12:00']]]],
73+
[$element => [2, '2022-08-06', [['08:00', '12:00']]]],
74+
[$element => [3, '2022-08-06', [['08:00', '12:00']]]],
75+
[$element => [3, '2022-08-10', [['08:00', '12:00']]]],
76+
],
77+
'matchedTimechecks' => [],
78+
'invalidates' => [[1, '2022-08-04'], [2, '2022-08-05/2022-08-06'], [3, '2022-08-06'], [3, '2022-08-10']],
79+
];
80+
} else {
81+
$r = [
82+
'eventClass' => $eventClass,
83+
'changes' => [
84+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
85+
[$element => [1, '2022-08-05', [['08:00', '12:00']]]],
86+
],
87+
'matchedTimechecks' => [],
88+
'invalidates' => [],
89+
'persistenceEnabled' => false,
90+
];
91+
}
92+
93+
assertType("non-empty-array<literal-string&non-falsy-string, array{}|(array{42: array{1, '2022-08-05T00:05/2022-08-05T02:00'}}&oversized-array)|bool|(list{0: array{1, '2022-08-04'}|non-empty-array{old?: array{0: 1, 1: '2022-08-04', 2: array{array{'16:00', '23:00'}}, matchedTimecheckIds: array{42}}, new?: array{0: 1, 1: '2022-08-04', 2: array{array{'16:00', '23:00'}}, matchedTimecheckIds: array{42}}}|non-empty-array{old?: array{1, '2022-08-04', array{0: array{'08:00', '12:00'}, 1?: array{'14:00', '18:00'}}}, new?: array{1, '2022-08-04', array{0: array{'08:00', '12:00'}, 1?: array{'14:00', '18:00'}}}}|non-empty-array{old?: array{1, '2022-08-04', array{array{'00:00', '00:00'}}}, new?: array{1, '2022-08-04', array{array{'00:00', '00:00'}}}}|non-empty-array{old?: array{1, '2022-08-04', array{array{'22:00', '04:00'}}}, new?: array{1, '2022-08-04', array{array{'22:00', '04:00'}}}}, 1?: array{1, '2022-08-10'}|array{2, '2022-08-05/2022-08-06'}|non-empty-array{old?: array{1, '2022-08-05', array{array{'08:00', '12:00'}}}, new?: array{1, '2022-08-05', array{array{'08:00', '12:00'}}}}|non-empty-array{old?: array{1, '2022-08-10', array{array{'08:00', '12:00'}}}, new?: array{1, '2022-08-10', array{array{'08:00', '12:00'}}}}, 2?: array{3, '2022-08-06'}|non-empty-array{old?: array{1, '2022-08-06', array{array{'08:00', '12:00'}}}, new?: array{1, '2022-08-06', array{array{'08:00', '12:00'}}}}, 3?: array{3, '2022-08-10'}}&oversized-array)|(list{array{1, '2022-08-04/2022-08-05'|'2022-08-04/2022-08-06'}}&oversized-array)|(list{non-empty-array{old?: array{1, '2022-08-04', array{array{'08:00', '12:00'}}}, new?: array{1, '2022-08-04', array{array{'08:00', '12:00'}}}}, non-empty-array{old?: array{2, '2022-08-05', array{array{'08:00', '12:00'}}}, new?: array{2, '2022-08-05', array{array{'08:00', '12:00'}}}}, non-empty-array{old?: array{2, '2022-08-06', array{array{'08:00', '12:00'}}}, new?: array{2, '2022-08-06', array{array{'08:00', '12:00'}}}}, non-empty-array{old?: array{3, '2022-08-06', array{array{'08:00', '12:00'}}}, new?: array{3, '2022-08-06', array{array{'08:00', '12:00'}}}}, non-empty-array{old?: array{3, '2022-08-10', array{array{'08:00', '12:00'}}}, new?: array{3, '2022-08-10', array{array{'08:00', '12:00'}}}}}&oversized-array)|string>&oversized-array", $r);
94+
95+
return $r;
96+
}

tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php

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

85+
public function testYieldOversizedSelfRejection(): void
86+
{
87+
$this->analyse([__DIR__ . '/data/bug-yield-oversized-self-rejection.php'], []);
88+
}
89+
8590
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace BugYieldOversizedSelfRejection;
4+
5+
use Generator;
6+
7+
/**
8+
* @param callable(string): iterable<string, mixed> $fn
9+
* @return Generator<string, mixed>
10+
*/
11+
function scollect(callable $fn): Generator
12+
{
13+
foreach (['Created', 'Deleted'] as $eventClass) {
14+
yield from $fn($eventClass);
15+
}
16+
}
17+
18+
scollect(static function (string $eventClass): iterable {
19+
$element = 'Deleted' === $eventClass ? 'old' : 'new';
20+
21+
yield '1' => [
22+
'eventClass' => $eventClass,
23+
'changes' => [[$element => [1, '2022-08-04', [['08:00', '12:00']]]]],
24+
'matchedTimechecks' => [],
25+
'invalidates' => [[1, '2022-08-04']],
26+
];
27+
yield '2' => [
28+
'eventClass' => $eventClass,
29+
'changes' => [[$element => [1, '2022-08-04', [['08:00', '12:00'], ['14:00', '18:00']]]]],
30+
'matchedTimechecks' => [],
31+
'invalidates' => [[1, '2022-08-04']],
32+
];
33+
yield '3' => [
34+
'eventClass' => $eventClass,
35+
'changes' => [[$element => [1, '2022-08-04', [['00:00', '00:00']]]]],
36+
'matchedTimechecks' => [],
37+
'invalidates' => [[1, '2022-08-04']],
38+
];
39+
yield '4' => [
40+
'eventClass' => $eventClass,
41+
'changes' => [[$element => [1, '2022-08-04', [['22:00', '04:00']]]]],
42+
'matchedTimechecks' => [],
43+
'invalidates' => [[1, '2022-08-04/2022-08-05']],
44+
];
45+
yield '5' => [
46+
'eventClass' => $eventClass,
47+
'changes' => [[$element => [1, '2022-08-04', [['16:00', '23:00']], 'matchedTimecheckIds' => [42]]]],
48+
'matchedTimechecks' => [42 => [1, '2022-08-05T00:05/2022-08-05T02:00']],
49+
'invalidates' => [[1, '2022-08-04/2022-08-05']],
50+
];
51+
yield '6' => [
52+
'eventClass' => $eventClass,
53+
'changes' => [
54+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
55+
[$element => [1, '2022-08-10', [['08:00', '12:00']]]],
56+
],
57+
'matchedTimechecks' => [],
58+
'invalidates' => [[1, '2022-08-04'], [1, '2022-08-10']],
59+
];
60+
yield '7' => [
61+
'eventClass' => $eventClass,
62+
'changes' => [
63+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
64+
[$element => [1, '2022-08-05', [['08:00', '12:00']]]],
65+
[$element => [1, '2022-08-06', [['08:00', '12:00']]]],
66+
],
67+
'matchedTimechecks' => [],
68+
'invalidates' => [[1, '2022-08-04/2022-08-06']],
69+
];
70+
yield '8' => [
71+
'eventClass' => $eventClass,
72+
'changes' => [
73+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
74+
[$element => [2, '2022-08-05', [['08:00', '12:00']]]],
75+
[$element => [2, '2022-08-06', [['08:00', '12:00']]]],
76+
[$element => [3, '2022-08-06', [['08:00', '12:00']]]],
77+
[$element => [3, '2022-08-10', [['08:00', '12:00']]]],
78+
],
79+
'matchedTimechecks' => [],
80+
'invalidates' => [[1, '2022-08-04'], [2, '2022-08-05/2022-08-06'], [3, '2022-08-06'], [3, '2022-08-10']],
81+
];
82+
yield '9' => [
83+
'eventClass' => $eventClass,
84+
'changes' => [
85+
[$element => [1, '2022-08-04', [['08:00', '12:00']]]],
86+
[$element => [1, '2022-08-05', [['08:00', '12:00']]]],
87+
],
88+
'matchedTimechecks' => [],
89+
'invalidates' => [],
90+
'persistenceEnabled' => false,
91+
];
92+
});

0 commit comments

Comments
 (0)