@@ -136,6 +136,99 @@ public function sealedBecomesUnsealed(string $s, int $i): void
136136 assertType ('non-empty-array<string, \'foo \'> ' , $ e );
137137 }
138138
139+ /**
140+ * Loop iteration's `generalizeType` previously widened the integer key
141+ * of a constant array shape to `int<0, max>` whenever the prev/current
142+ * iterations had different (but finite) key sets. With the fix that
143+ * keeps the constant-array key union when both shapes are sealed,
144+ * loop-bounded counters stay within their actual range.
145+ */
146+ public function loopBoundedCounter (): void
147+ {
148+ $ arr = [];
149+ for ($ i = 0 ; $ i < 5 ; $ i ++) {
150+ $ arr [$ i ] = 'v ' ;
151+ }
152+ assertType ("non-empty-array<int<0, 4>, 'v'> " , $ arr );
153+ }
154+
155+ public function loopBoundedCounterWithCondition (): void
156+ {
157+ $ arr = [];
158+ for ($ i = 0 ; $ i < 5 ; $ i ++) {
159+ if (rand ()) {
160+ $ arr [$ i ] = 'v ' ;
161+ }
162+ }
163+ assertType ("array<int<0, 4>, 'v'> " , $ arr );
164+ }
165+
166+ /**
167+ * The existing `'x'` key keeps its sealed slot through all iterations
168+ * while the int counter grows; generalize merges the two sealed shapes
169+ * via key union (no widening to `int<0, max>`).
170+ */
171+ public function loopWithExistingSealedKey (): void
172+ {
173+ $ arr = ['x ' => 0 ];
174+ for ($ i = 0 ; $ i < 5 ; $ i ++) {
175+ $ arr [$ i ] = $ i ;
176+ }
177+ assertType ("non-empty-array<'x'|int<0, 4>, int<0, max>> " , $ arr );
178+ }
179+
180+ /**
181+ * Each iteration the body assigns a sealed constant key, then a
182+ * non-constant offset — that second assignment promotes the array
183+ * from sealed to unsealed (folding the unknown offset/value into the
184+ * unsealed extras). The iteration's converged shape stays bounded by
185+ * the loop's cond instead of widening to `int<0, max>`.
186+ */
187+ public function loopSealedBecomesUnsealedEachIteration (string $ s ): void
188+ {
189+ $ arr = [];
190+ for ($ i = 0 ; $ i < 3 ; $ i ++) {
191+ $ arr [$ i ] = 'sealed ' ;
192+ $ arr [$ s . '_ ' . $ i ] = 'unsealed ' ;
193+ }
194+ assertType ("non-empty-array<int<0, 2>|non-falsy-string, literal-string&lowercase-string&non-falsy-string> " , $ arr );
195+ }
196+
197+ /**
198+ * Starting from a PHPDoc-declared unsealed shape, a loop adds further
199+ * non-constant entries. The sealed prefix (`a`) survives, the existing
200+ * unsealed extras get unioned with the loop's per-iteration extras.
201+ */
202+ public function loopMergesUnsealedExtras (string $ key ): void
203+ {
204+ /** @var array{a: int, ...<string, int>} $arr */
205+ $ arr = ['a ' => 1 ];
206+ for ($ i = 0 ; $ i < 3 ; $ i ++) {
207+ $ arr [$ key . $ i ] = $ i ;
208+ }
209+ assertType ("array{a: int, ...<string, int>} " , $ arr );
210+ }
211+
212+ /**
213+ * Joining two unsealed shapes with disjoint sealed prefixes via
214+ * scope merging collapses the result to a general array of
215+ * `string => int` — neither sealed prefix survives because each is
216+ * optional from the other branch's perspective and the unsealed
217+ * extras of both sides cover the same key/value space.
218+ *
219+ * @param array{a: int, ...<string, int>} $u1
220+ * @param array{b: int, ...<string, int>} $u2
221+ */
222+ public function twoUnsealedJoined (array $ u1 , array $ u2 , bool $ cond ): void
223+ {
224+ if ($ cond ) {
225+ $ arr = $ u1 ;
226+ } else {
227+ $ arr = $ u2 ;
228+ }
229+ assertType ("non-empty-array<string, int> " , $ arr );
230+ }
231+
139232}
140233
141234class Generics
0 commit comments