diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index a5df088329d..2bb9a28f54d 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1225,6 +1225,13 @@ public function applyWrite( $isAlwaysTerminating = $isAlwaysTerminating || $nameExprResult->isAlwaysTerminating(); $scope = $nameExprResult->getScope(); } + + if (!is_string($var->name)) { + // a dynamic $$name write can target any variable, including the + // foreach value/key/iteratee - drop the value aliases so a later + // narrowing is not projected onto a dim fetch it may have desynced + $scope = $scope->invalidateForeachValueAliases(); + } } elseif ($kind === PreparedAssignTarget::KIND_ARRAY_DIM_FETCH) { if (!$var instanceof ArrayDimFetch) { throw new ShouldNotHappenException(); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 96f4301f6df..23caf1399a9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -31,6 +31,7 @@ use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\CloneReinitializationExpr; +use PHPStan\Node\Expr\ForeachValueAliasExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; @@ -494,23 +495,28 @@ public function canAnyVariableExist(): bool public function afterExtractCall(): self { - return $this->scopeFactory->create( - $this->context, - $this->isDeclareStrictTypes(), - $this->getFunction(), - $this->getNamespace(), - $this->expressionTypes, - $this->nativeExpressionTypes, + // extract() may (re)define any variable, including the foreach value/key/ + // iteratee - drop the value aliases so a later narrowing is not projected + // onto a dim fetch the extracted values may have desynced. + $scope = $this->invalidateForeachValueAliases(); + + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, [], - $this->inClosureBindScopeClasses, - $this->anonymousFunctionReflection, - $this->isInFirstLevelStatement(), - $this->currentlyAssignedExpressions, - $this->currentlyAllowedUndefinedExpressions, - $this->inFunctionCallsStack, + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->isInFirstLevelStatement(), + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, true, - $this->parentScope, - $this->nativeTypesPromoted, + $scope->parentScope, + $scope->nativeTypesPromoted, ); } @@ -2759,7 +2765,7 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): self + public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef, bool $recordValueAlias = true): self { $valueType = $originalScope->getIterableValueType($iterateeType); $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); @@ -2795,6 +2801,23 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee if ($keyName !== null) { $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName); + if ($recordValueAlias && $iterateeType->isArray()->yes()) { + // for the current iteration the value variable and the iteratee dim + // fetch alias one runtime value - narrowings landed on the value + // variable are projected onto the tracked dim fetch through this + // link (applySpecifiedTypes()); a write to any of the three + // participating expressions invalidates it through containment. + // The alias is only recorded when the loop body does not mutate the + // iteratee at a foreign key or reassign the key ($recordValueAlias, + // decided in ForeachHandler): a cross-iteration write would desync + // $array[$key] from the snapshot value variable. + $scope = $scope->assignExpression( + new ForeachValueAliasExpr($valueName, new Expr\ArrayDimFetch($iteratee, new Variable($keyName))), + $valueType, + $nativeValueType, + ); + } + if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, new Expr\ArrayDimFetch($iteratee, new Variable($keyName)), new Variable($valueName)), @@ -3577,6 +3600,57 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require ); } + /** + * Drops every foreach value-variable alias (ForeachValueAliasExpr). The alias + * is a precision optimization whose soundness depends on the value variable + * still holding the iteratee element - code paths that write a variable + * without going through the assignment-time containment invalidation (a by-ref + * closure use, extract(), a dynamic $$name write) must sever it here, because + * they may have desynced the value variable from $array[$key]. Losing the alias + * conservatively is always sound; the element type falls back to the iterable + * value type. + */ + public function invalidateForeachValueAliases(): self + { + $changed = false; + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + foreach ([$this->expressionTypes, $this->nativeExpressionTypes] as $types) { + foreach (array_keys($types) as $exprString) { + if (!str_starts_with($exprString, ForeachValueAliasExpr::KEY_PREFIX)) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $changed = true; + } + } + + if (!$changed) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + /** @internal called by ScopeOps */ public function isPrivatePropertyOfDifferentClass(Expr $expr, ClassReflection $invalidatingClass): bool { @@ -3968,6 +4042,61 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$exprString] = ExpressionTypeHolder::createYes($expr, $holderType); } + // while a foreach value variable still aliases the iteratee dim fetch + // (ForeachValueAliasExpr link intact - none of the participating + // expressions were written), a narrowing landed on the value variable + // also narrows the tracked dim fetch: they hold the same runtime value + foreach ($specifiedExpressions as $specifiedHolder) { + $specifiedExpr = $specifiedHolder->getExpr(); + if (!$specifiedExpr instanceof Variable || !is_string($specifiedExpr->name)) { + continue; + } + $aliasHolder = $scope->expressionTypes[ForeachValueAliasExpr::key($specifiedExpr->name)] ?? null; + if ($aliasHolder === null || !$aliasHolder->getCertainty()->yes()) { + continue; + } + $aliasExpr = $aliasHolder->getExpr(); + if (!$aliasExpr instanceof ForeachValueAliasExpr) { + continue; + } + $valueExprString = '$' . $specifiedExpr->name; + $valueHolder = $scope->expressionTypes[$valueExprString] ?? null; + $valueNativeHolder = $scope->nativeExpressionTypes[$valueExprString] ?? null; + if ( + $valueHolder === null || !$valueHolder->getCertainty()->yes() + || $valueNativeHolder === null || !$valueNativeHolder->getCertainty()->yes() + ) { + continue; + } + $dimFetchExpr = $aliasExpr->getDimFetch(); + $dimFetchString = $scope->getNodeKey($dimFetchExpr); + $dimFetchHolder = $scope->expressionTypes[$dimFetchString] ?? null; + $dimFetchNativeHolder = $scope->nativeExpressionTypes[$dimFetchString] ?? null; + if ( + $dimFetchHolder === null || !$dimFetchHolder->getCertainty()->yes() + || $dimFetchNativeHolder === null || !$dimFetchNativeHolder->getCertainty()->yes() + ) { + continue; + } + if ($scope->isComplexUnionType($dimFetchHolder->getType())) { + continue; + } + + $newDimFetchType = TypeCombinator::intersect($dimFetchHolder->getType(), $valueHolder->getType()); + $newDimFetchNativeType = TypeCombinator::intersect($dimFetchNativeHolder->getType(), $valueNativeHolder->getType()); + if ( + $newDimFetchType->equals($dimFetchHolder->getType()) + && $newDimFetchNativeType->equals($dimFetchNativeHolder->getType()) + ) { + continue; + } + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($dimFetchExpr, $newDimFetchType, $newDimFetchNativeType, TrinaryLogic::createYes()); + } + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); $newConditionalExpressionHolders = $specifiedTypes->getNewConditionalExpressionHolders(); @@ -4426,6 +4555,22 @@ public function processClosureScope( $nativeExpressionTypes[$variableExprString] = $holder; } + // The by-ref uses above write variable holders directly, bypassing the + // assignment-time containment invalidation. A by-ref use may write the + // foreach value/key/iteratee variable, desyncing the value variable from + // $array[$key] - drop the value aliases so a later narrowing is not + // projected onto the tracked dim fetch. + foreach ([$expressionTypes, $nativeExpressionTypes] as $types) { + foreach (array_keys($types) as $exprString) { + if (!str_starts_with($exprString, ForeachValueAliasExpr::KEY_PREFIX)) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + } + } + return $this->scopeFactory->create( $this->context, $this->isDeclareStrictTypes(), diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index d80e4dccf1a..2082a6d243a 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -19,6 +19,7 @@ use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Continue_; use PhpParser\Node\Stmt\Foreach_; +use PhpParser\NodeFinder; use PHPStan\Analyser\ConditionalExpressionHolder; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; @@ -73,6 +74,7 @@ final class ForeachHandler implements StmtHandler private const FOREACH_UNROLL_LIMIT = 16; private const FOREACH_UNROLL_NESTED_LIMIT = 8; + private const RECORD_VALUE_ALIAS_ATTRIBUTE = 'foreachValueAliasRecordable'; public function __construct( private Container $container, @@ -500,6 +502,7 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop $stmt->valueVar->name, $keyVarName, $stmt->byRef, + $this->shouldRecordForeachValueAlias($stmt), ); $vars = [$stmt->valueVar->name]; if ($keyVarName !== null) { @@ -605,6 +608,210 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop return $this->varAnnotationProcessor->processVarAnnotation($scope, $vars, $stmt); } + /** + * Whether the foreach value variable may alias the iteratee dim fetch + * ($array[$key]) for its whole iteration. `foreach` iterates a snapshot, so + * the alias is unsound once the body mutates the iteratee at a foreign key, + * reassigns the iteratee, or lets it escape by reference: a later iteration + * would read a live element the snapshot value variable no longer matches. + * A same-key write ($array[$key] = ...) is kept - it only touches the current + * iteration's element, invalidated in-body through containment. The verdict is + * static, so it is cached on the loop node across convergence passes. + */ + private function shouldRecordForeachValueAlias(Foreach_ $stmt): bool + { + $cached = $stmt->getAttribute(self::RECORD_VALUE_ALIAS_ATTRIBUTE); + if ($cached !== null) { + return $cached; + } + + $result = $this->computeShouldRecordForeachValueAlias($stmt); + $stmt->setAttribute(self::RECORD_VALUE_ALIAS_ATTRIBUTE, $result); + + return $result; + } + + private function computeShouldRecordForeachValueAlias(Foreach_ $stmt): bool + { + // the value alias exists only for `foreach ($var as $key => $value)` over a + // plain-variable iteratee with plain key/value variables + if (!$stmt->expr instanceof Variable || !is_string($stmt->expr->name)) { + return false; + } + if (!$stmt->keyVar instanceof Variable || !is_string($stmt->keyVar->name)) { + return false; + } + if (!$stmt->valueVar instanceof Variable || !is_string($stmt->valueVar->name)) { + return false; + } + + $iterateeName = $stmt->expr->name; + $keyName = $stmt->keyVar->name; + + $desyncingNode = (new NodeFinder())->findFirst( + $stmt->stmts, + fn (Node $node): bool => $this->foreachAliasDesyncingNode($node, $iterateeName, $keyName), + ); + + return $desyncingNode === null; + } + + private function foreachAliasDesyncingNode(Node $node, string $iterateeName, string $keyName): bool + { + if ($node instanceof Assign || $node instanceof Expr\AssignRef || $node instanceof Expr\AssignOp) { + return $this->foreachAliasDesyncingTarget($node->var, $iterateeName, $keyName); + } + + if ( + $node instanceof Expr\PreInc || $node instanceof Expr\PreDec + || $node instanceof Expr\PostInc || $node instanceof Expr\PostDec + ) { + return $this->foreachAliasDesyncingTarget($node->var, $iterateeName, $keyName); + } + + if ($node instanceof Stmt\Unset_) { + foreach ($node->vars as $var) { + if ($this->foreachAliasDesyncingTarget($var, $iterateeName, $keyName)) { + return true; + } + } + + return false; + } + + if ($node instanceof Expr\Closure) { + foreach ($node->uses as $use) { + if ( + $use->byRef + && is_string($use->var->name) + && ($use->var->name === $iterateeName || $use->var->name === $keyName) + ) { + return true; + } + } + + return false; + } + + if ($node instanceof Stmt\Global_) { + foreach ($node->vars as $var) { + if (!$var instanceof Variable) { + continue; + } + // `global $iteratee` rebinds the iteratee to the global; a dynamic + // `global $$name` may rebind it too - drop the alias in both cases + if (!is_string($var->name) || $var->name === $iterateeName) { + return true; + } + } + + return false; + } + + if ($node instanceof Stmt\Static_) { + foreach ($node->vars as $staticVar) { + if (is_string($staticVar->var->name) && $staticVar->var->name === $iterateeName) { + return true; + } + } + + return false; + } + + if ($node instanceof Foreach_ && $node->byRef) { + // foreach ($iteratee as &$x) writes elements of the iteratee back + return $node->expr instanceof Variable && is_string($node->expr->name) && $node->expr->name === $iterateeName; + } + + if ( + $node instanceof FuncCall || $node instanceof Expr\MethodCall + || $node instanceof Expr\NullsafeMethodCall || $node instanceof Expr\StaticCall + || $node instanceof Expr\New_ + ) { + // the whole iteratee (or the key) passed to a call may be mutated by + // reference - conservatively treat it as a desync + foreach ($node->getArgs() as $arg) { + if ( + $arg->value instanceof Variable + && is_string($arg->value->name) + && ($arg->value->name === $iterateeName || $arg->value->name === $keyName) + ) { + return true; + } + } + + return false; + } + + return false; + } + + /** + * Conservatively complete: the value alias is a pure precision optimization, + * so any write target the analyzer cannot PROVE is exactly $iteratee[$key] + * (or a sub-offset of it) or a distinctly named non-iteratee location drops + * the alias. In particular a dynamic-variable base ($$name / ${expr}) may + * resolve to the iteratee, key or value variable at runtime, so it always + * desyncs; only a same-key iteratee write ($iteratee[$key]...) and writes to + * provably distinct plain variables / non-variable storage are kept. + */ + private function foreachAliasDesyncingTarget(Expr $target, string $iterateeName, string $keyName): bool + { + if ($target instanceof List_ || $target instanceof Array_) { + foreach ($target->items as $item) { + if ($item === null) { + continue; + } + if ($this->foreachAliasDesyncingTarget($item->value, $iterateeName, $keyName)) { + return true; + } + } + + return false; + } + + if ($target instanceof Variable) { + // a dynamic $$name / ${expr} write may target the iteratee (or the key + // or value variable) - we cannot prove otherwise, so drop the alias + if (!is_string($target->name)) { + return true; + } + + // whole-iteratee reassignment ($iteratee = ...); a plain write to a + // differently named variable (including a static write to the key or + // value variable, which is severed through containment) does not desync + // across iterations + return $target->name === $iterateeName; + } + + // A write into an array offset is safe only when it provably targets the + // iteratee at exactly the current key ($iteratee[$key]...) - keeping the + // #7508 same-key sub-offset case - or provably targets a distinctly named + // plain variable. Peel the ArrayDimFetch layers to the base variable; a + // dynamic-variable base ($$name[...]) that may be the iteratee, or an + // iteratee write at any other offset / an append ($iteratee[]), drops the + // alias. A non-variable base (a property, etc.) cannot rebind the plain + // iteratee variable and is kept. + $node = $target; + while ($node instanceof ArrayDimFetch) { + $inner = $node->var; + if ($inner instanceof Variable) { + if (!is_string($inner->name)) { + return true; + } + if ($inner->name === $iterateeName) { + return !($node->dim instanceof Variable && is_string($node->dim->name) && $node->dim->name === $keyName); + } + + return false; + } + + $node = $inner; + } + + return false; + } + /** * @return array{bodyScope: MutatingScope, endScope: MutatingScope, totalKeys: int}|null */ diff --git a/src/Node/Expr/ForeachValueAliasExpr.php b/src/Node/Expr/ForeachValueAliasExpr.php new file mode 100644 index 00000000000..58dc9d14985 --- /dev/null +++ b/src/Node/Expr/ForeachValueAliasExpr.php @@ -0,0 +1,61 @@ +var = new Expr\Variable($this->variableName); + } + + public function getVariableName(): string + { + return $this->variableName; + } + + public function getDimFetch(): Expr\ArrayDimFetch + { + return $this->dimFetch; + } + + /** The expression key this node prints to - derivable from the value variable name alone. */ + public static function key(string $variableName): string + { + return sprintf('%s%s)', self::KEY_PREFIX, $variableName); + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_ForeachValueAliasExpr'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return ['var', 'dimFetch']; + } + +} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 61ffd313960..99a6c6b2690 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -13,6 +13,7 @@ use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\CloneReinitializationExpr; use PHPStan\Node\Expr\ExistingArrayDimFetch; +use PHPStan\Node\Expr\ForeachValueAliasExpr; use PHPStan\Node\Expr\ForeachValueByRefExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; @@ -173,6 +174,11 @@ protected function pPHPStan_Node_OriginalForeachValueExpr(OriginalForeachValueEx return sprintf('__phpstanOriginalForeachValue(%s)', $expr->getVariableName()); } + protected function pPHPStan_Node_ForeachValueAliasExpr(ForeachValueAliasExpr $expr): string // phpcs:ignore + { + return ForeachValueAliasExpr::key($expr->getVariableName()); + } + protected function pPHPStan_Node_IntertwinedVariableByReferenceWithExpr(IntertwinedVariableByReferenceWithExpr $expr): string // phpcs:ignore { return sprintf('__phpstanIntertwinedVariableByReference(%s, %s, %s)', $expr->getVariableName(), $this->p($expr->getExpr()), $this->p($expr->getAssignedExpr())); diff --git a/tests/PHPStan/Analyser/nsrt/bug-7508.php b/tests/PHPStan/Analyser/nsrt/bug-7508.php new file mode 100644 index 00000000000..87af8cca532 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7508.php @@ -0,0 +1,78 @@ + $data + */ +function loopy (array $data ): void { + + foreach ($data as $key =>$value) { + if(!is_array($value)) { + continue; + } + assertType('array', $data[$key]); + $data[$key][0] = 'test'; + + } +} + +/** + * @param array $data + */ +function loopy2 (array $data ): void { + + foreach ($data as $key =>$value) { + if(!is_int($value)) { + continue; + } + // Expected int, got mixed + assertType('int', $data[$key]); + + } +} + +/** + * @param array $data + */ +function loopyValueReassigned (array $data ): void { + + foreach ($data as $key => $value) { + if(!is_int($value)) { + continue; + } + // the element itself did not change - the narrowing persists + $value = 'foo'; + assertType('int', $data[$key]); + } +} + +/** + * @param array $data + */ +function loopyKeyReassigned (array $data ): void { + + foreach ($data as $key => $value) { + if(!is_int($value)) { + continue; + } + $key = 'foo'; + assertType('mixed', $data[$key]); + } +} + +/** + * @param array $data + */ +function loopyIterateeReassigned (array $data ): void { + + foreach ($data as $key => $value) { + $data[$key] = 1; + if(!is_string($value)) { + continue; + } + assertType('1', $data[$key]); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/foreach-value-alias-soundness.php b/tests/PHPStan/Analyser/nsrt/foreach-value-alias-soundness.php new file mode 100644 index 00000000000..1966677c567 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/foreach-value-alias-soundness.php @@ -0,0 +1,166 @@ + $data + */ +function byRefClosureUse(array $data): void +{ + foreach ($data as $key => $value) { + $fn = function () use (&$value): void { + $value = 5; + }; + $fn(); + if (is_int($value)) { + // the closure desynced $value from $data[$key]: the element is unchanged + assertType('int|string', $data[$key]); + } + } +} + +/** + * @param array $data + */ +function dynamicWrite(array $data): void +{ + foreach ($data as $key => $value) { + $name = 'value'; + $$name = 5; + if (is_int($value)) { + assertType('int|string', $data[$key]); + } + } +} + +/** + * A dynamic-variable dim write ($$name is $data) into the iteratee at a foreign + * key desyncs the live element from the snapshot value variable, exactly like a + * statically named $data[$key + 1] write - the dynamic base must not slip past + * the alias detector. + * + * @param array $data + */ +function dynamicDimWrite(array $data): void +{ + foreach ($data as $key => $value) { + $name = 'data'; + $$name[$key + 1] = 'str'; + if (is_int($value)) { + assertType('int|string', $data[$key]); + } + } +} + +/** + * A dynamic write to the key variable ($$name is $key) means $data[$key] no + * longer refers to the snapshot value's element. + * + * @param array $data + */ +function dynamicKeyWrite(array $data): void +{ + foreach ($data as $key => $value) { + $name = 'key'; + $$name = 99; + if (is_int($value)) { + assertType('int|string', $data[$key]); + } + } +} + +/** + * A curly-brace dynamic variable (${'data'}) is a Variable with an Expr name in + * the AST, so a dim write through it must be treated as a possible iteratee write. + * + * @param array $data + */ +function curlyDynamicDimWrite(array $data): void +{ + foreach ($data as $key => $value) { + ${'data'}[$key + 1] = 'str'; + if (is_int($value)) { + assertType('int|string', $data[$key]); + } + } +} + +/** + * @param array $data + * @param array $vars + */ +function extractCall(array $data, array $vars): void +{ + foreach ($data as $key => $value) { + extract($vars); + if (is_int($value)) { + assertType('int|string', $data[$key]); + } + } +} + +/** + * @param array $data + */ +function crossIterationIterateeWrite(array $data): void +{ + foreach ($data as $key => $value) { + if (is_int($value)) { + // a previous iteration may have written this element via $data[$key + 1] + assertType('int|string', $data[$key]); + } + $data[$key + 1] = 'str'; + } +} + +/** + * @param array $data + */ +function crossIterationCallEscape(array $data): void +{ + foreach ($data as $key => $value) { + if (is_int($value)) { + assertType('int|string', $data[$key]); + } + sort($data); + } +} + +/** + * The legitimate same-iteration case with no intervening write keeps narrowing. + * + * @param array $data + */ +function sameIterationNoWrite(array $data): void +{ + foreach ($data as $key => $value) { + if (is_int($value)) { + assertType('int', $data[$key]); + } + } +} + +/** + * A same-key write is kept - it only touches the current iteration's element. + * + * @param array $data + */ +function sameKeyWrite(array $data): void +{ + foreach ($data as $key => $value) { + if (!is_array($value)) { + continue; + } + assertType('array', $data[$key]); + $data[$key][0] = 'test'; + } +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 9bd4e9c6b15..092a5889ad3 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1358,6 +1358,13 @@ public function testBug13688(): void $this->analyse([__DIR__ . '/data/bug-13688.php'], []); } + public function testBug7508(): void + { + $this->checkExplicitMixed = true; + + $this->analyse([__DIR__ . '/data/bug-7508.php'], []); + } + public static function dataUnsealedArrayShapes(): iterable { foreach ([false, true] as $reportPossiblyNonexistentGeneralArrayOffset) { diff --git a/tests/PHPStan/Rules/Arrays/data/bug-7508.php b/tests/PHPStan/Rules/Arrays/data/bug-7508.php new file mode 100644 index 00000000000..7c64aa65188 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-7508.php @@ -0,0 +1,17 @@ + $data + */ +function loopy (array $data ): void { + + foreach ($data as $key =>$value) { + if(!is_array($value)) { + continue; + } + $data[$key][0] = 'test'; + + } +} diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 718a35b370b..bb380b3e642 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1364,4 +1364,9 @@ public function testBug14893(): void $this->analyse([__DIR__ . '/data/bug-14893.php'], []); } + public function testBug12500(): void + { + $this->analyse([__DIR__ . '/data/bug-12500.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-12500.php b/tests/PHPStan/Rules/Methods/data/bug-12500.php new file mode 100644 index 00000000000..7075b5f28ca --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12500.php @@ -0,0 +1,17 @@ + $input + * @return array + */ + public function clean(array $input): array { + foreach ($input as $k => $v) { + if (\is_int($v)) { $input[$k] = 'was-int'; } + } + return $input; + } +}