@@ -495,23 +495,28 @@ public function canAnyVariableExist(): bool
495495
496496 public function afterExtractCall (): self
497497 {
498- return $ this ->scopeFactory ->create (
499- $ this ->context ,
500- $ this ->isDeclareStrictTypes (),
501- $ this ->getFunction (),
502- $ this ->getNamespace (),
503- $ this ->expressionTypes ,
504- $ this ->nativeExpressionTypes ,
498+ // extract() may (re)define any variable, including the foreach value/key/
499+ // iteratee - drop the value aliases so a later narrowing is not projected
500+ // onto a dim fetch the extracted values may have desynced.
501+ $ scope = $ this ->invalidateForeachValueAliases ();
502+
503+ return $ scope ->scopeFactory ->create (
504+ $ scope ->context ,
505+ $ scope ->isDeclareStrictTypes (),
506+ $ scope ->getFunction (),
507+ $ scope ->getNamespace (),
508+ $ scope ->expressionTypes ,
509+ $ scope ->nativeExpressionTypes ,
505510 [],
506- $ this ->inClosureBindScopeClasses ,
507- $ this ->anonymousFunctionReflection ,
508- $ this ->isInFirstLevelStatement (),
509- $ this ->currentlyAssignedExpressions ,
510- $ this ->currentlyAllowedUndefinedExpressions ,
511- $ this ->inFunctionCallsStack ,
511+ $ scope ->inClosureBindScopeClasses ,
512+ $ scope ->anonymousFunctionReflection ,
513+ $ scope ->isInFirstLevelStatement (),
514+ $ scope ->currentlyAssignedExpressions ,
515+ $ scope ->currentlyAllowedUndefinedExpressions ,
516+ $ scope ->inFunctionCallsStack ,
512517 true ,
513- $ this ->parentScope ,
514- $ this ->nativeTypesPromoted ,
518+ $ scope ->parentScope ,
519+ $ scope ->nativeTypesPromoted ,
515520 );
516521 }
517522
@@ -2760,7 +2765,7 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy
27602765 return $ this ->assignExpression ($ condExpr , $ type , $ nativeType );
27612766 }
27622767
2763- public function enterForeach (self $ originalScope , Expr $ iteratee , Type $ iterateeType , Type $ nativeIterateeType , string $ valueName , ?string $ keyName , bool $ valueByRef ): self
2768+ public function enterForeach (self $ originalScope , Expr $ iteratee , Type $ iterateeType , Type $ nativeIterateeType , string $ valueName , ?string $ keyName , bool $ valueByRef, bool $ recordValueAlias = true ): self
27642769 {
27652770 $ valueType = $ originalScope ->getIterableValueType ($ iterateeType );
27662771 $ nativeValueType = $ originalScope ->getIterableValueType ($ nativeIterateeType );
@@ -2796,12 +2801,16 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee
27962801 if ($ keyName !== null ) {
27972802 $ scope = $ scope ->enterForeachKey ($ originalScope , $ iteratee , $ iterateeType , $ nativeIterateeType , $ keyName );
27982803
2799- if ($ iterateeType ->isArray ()->yes ()) {
2804+ if ($ recordValueAlias && $ iterateeType ->isArray ()->yes ()) {
28002805 // for the current iteration the value variable and the iteratee dim
28012806 // fetch alias one runtime value - narrowings landed on the value
28022807 // variable are projected onto the tracked dim fetch through this
28032808 // link (applySpecifiedTypes()); a write to any of the three
2804- // participating expressions invalidates it through containment
2809+ // participating expressions invalidates it through containment.
2810+ // The alias is only recorded when the loop body does not mutate the
2811+ // iteratee at a foreign key or reassign the key ($recordValueAlias,
2812+ // decided in ForeachHandler): a cross-iteration write would desync
2813+ // $array[$key] from the snapshot value variable.
28052814 $ scope = $ scope ->assignExpression (
28062815 new ForeachValueAliasExpr ($ valueName , new Expr \ArrayDimFetch ($ iteratee , new Variable ($ keyName ))),
28072816 $ valueType ,
@@ -3591,6 +3600,57 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require
35913600 );
35923601 }
35933602
3603+ /**
3604+ * Drops every foreach value-variable alias (ForeachValueAliasExpr). The alias
3605+ * is a precision optimization whose soundness depends on the value variable
3606+ * still holding the iteratee element - code paths that write a variable
3607+ * without going through the assignment-time containment invalidation (a by-ref
3608+ * closure use, extract(), a dynamic $$name write) must sever it here, because
3609+ * they may have desynced the value variable from $array[$key]. Losing the alias
3610+ * conservatively is always sound; the element type falls back to the iterable
3611+ * value type.
3612+ */
3613+ public function invalidateForeachValueAliases (): self
3614+ {
3615+ $ changed = false ;
3616+ $ expressionTypes = $ this ->expressionTypes ;
3617+ $ nativeExpressionTypes = $ this ->nativeExpressionTypes ;
3618+ foreach ([$ this ->expressionTypes , $ this ->nativeExpressionTypes ] as $ types ) {
3619+ foreach (array_keys ($ types ) as $ exprString ) {
3620+ if (!str_starts_with ($ exprString , ForeachValueAliasExpr::KEY_PREFIX )) {
3621+ continue ;
3622+ }
3623+
3624+ unset($ expressionTypes [$ exprString ]);
3625+ unset($ nativeExpressionTypes [$ exprString ]);
3626+ $ changed = true ;
3627+ }
3628+ }
3629+
3630+ if (!$ changed ) {
3631+ return $ this ;
3632+ }
3633+
3634+ return $ this ->scopeFactory ->create (
3635+ $ this ->context ,
3636+ $ this ->isDeclareStrictTypes (),
3637+ $ this ->getFunction (),
3638+ $ this ->getNamespace (),
3639+ $ expressionTypes ,
3640+ $ nativeExpressionTypes ,
3641+ $ this ->conditionalExpressions ,
3642+ $ this ->inClosureBindScopeClasses ,
3643+ $ this ->anonymousFunctionReflection ,
3644+ $ this ->isInFirstLevelStatement (),
3645+ $ this ->currentlyAssignedExpressions ,
3646+ $ this ->currentlyAllowedUndefinedExpressions ,
3647+ $ this ->inFunctionCallsStack ,
3648+ $ this ->afterExtractCall ,
3649+ $ this ->parentScope ,
3650+ $ this ->nativeTypesPromoted ,
3651+ );
3652+ }
3653+
35943654 /** @internal called by ScopeOps */
35953655 public function isPrivatePropertyOfDifferentClass (Expr $ expr , ClassReflection $ invalidatingClass ): bool
35963656 {
@@ -4495,6 +4555,22 @@ public function processClosureScope(
44954555 $ nativeExpressionTypes [$ variableExprString ] = $ holder ;
44964556 }
44974557
4558+ // The by-ref uses above write variable holders directly, bypassing the
4559+ // assignment-time containment invalidation. A by-ref use may write the
4560+ // foreach value/key/iteratee variable, desyncing the value variable from
4561+ // $array[$key] - drop the value aliases so a later narrowing is not
4562+ // projected onto the tracked dim fetch.
4563+ foreach ([$ expressionTypes , $ nativeExpressionTypes ] as $ types ) {
4564+ foreach (array_keys ($ types ) as $ exprString ) {
4565+ if (!str_starts_with ($ exprString , ForeachValueAliasExpr::KEY_PREFIX )) {
4566+ continue ;
4567+ }
4568+
4569+ unset($ expressionTypes [$ exprString ]);
4570+ unset($ nativeExpressionTypes [$ exprString ]);
4571+ }
4572+ }
4573+
44984574 return $ this ->scopeFactory ->create (
44994575 $ this ->context ,
45004576 $ this ->isDeclareStrictTypes (),
0 commit comments