From f84e8718a5e944292951eb19f29c98e3bfc81512 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:31:05 +0000 Subject: [PATCH] Narrow array key type after type-checking the key variable inside a `foreach` loop - Extend the existing value-type narrowing mechanism in NodeScopeResolver's foreach handling to also track and apply key-type narrowing - Collect key variable types from loop body end scopes and continue exit point scopes (same scopes used for value narrowing via OriginalForeachKeyExpr) - When the combined key type differs from the original iterable key type, create a new array type with the narrowed key type - Supports both key-only narrowing and combined key+value narrowing - Correctly does NOT narrow when: key variable is reassigned, break is used, continue without narrowing on all paths, or no explicit key variable --- src/Analyser/NodeScopeResolver.php | 26 ++- tests/PHPStan/Analyser/nsrt/bug-7076.php | 202 +++++++++++++++++++++++ 2 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7076.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f616b644ab3..945271fa847 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1315,21 +1315,30 @@ public function processStmtNode( ) { $arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $stmt->keyVar); $arrayDimFetchLoopTypes = []; + $keyLoopTypes = []; foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { $arrayDimFetchLoopTypes[] = $scopeWithIterableValueType->getType($arrayExprDimFetch); + $keyLoopTypes[] = $scopeWithIterableValueType->getType($stmt->keyVar); } $arrayDimFetchLoopType = TypeCombinator::union(...$arrayDimFetchLoopTypes); + $keyLoopType = TypeCombinator::union(...$keyLoopTypes); $arrayDimFetchLoopNativeTypes = []; + $keyLoopNativeTypes = []; foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { $arrayDimFetchLoopNativeTypes[] = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch); + $keyLoopNativeTypes[] = $scopeWithIterableValueType->getNativeType($stmt->keyVar); } $arrayDimFetchLoopNativeType = TypeCombinator::union(...$arrayDimFetchLoopNativeTypes); + $keyLoopNativeType = TypeCombinator::union(...$keyLoopNativeTypes); - if (!$arrayDimFetchLoopType->equals($exprType->getIterableValueType())) { - $newExprType = TypeTraverser::map($exprType, static function (Type $type, callable $traverse) use ($arrayDimFetchLoopType): Type { + $valueTypeChanged = !$arrayDimFetchLoopType->equals($exprType->getIterableValueType()); + $keyTypeChanged = !$keyLoopType->equals($exprType->getIterableKeyType()); + + if ($valueTypeChanged || $keyTypeChanged) { + $newExprType = TypeTraverser::map($exprType, static function (Type $type, callable $traverse) use ($arrayDimFetchLoopType, $keyLoopType, $valueTypeChanged, $keyTypeChanged): Type { if ($type instanceof UnionType || $type instanceof IntersectionType) { return $traverse($type); } @@ -1338,9 +1347,13 @@ public function processStmtNode( return $type; } - return new ArrayType($type->getKeyType(), $arrayDimFetchLoopType); + return new ArrayType( + $keyTypeChanged ? $keyLoopType : $type->getKeyType(), + $valueTypeChanged ? $arrayDimFetchLoopType : $type->getIterableValueType(), + ); }); - $newExprNativeType = TypeTraverser::map($scope->getNativeType($stmt->expr), static function (Type $type, callable $traverse) use ($arrayDimFetchLoopNativeType): Type { + $nativeExprType = $scope->getNativeType($stmt->expr); + $newExprNativeType = TypeTraverser::map($nativeExprType, static function (Type $type, callable $traverse) use ($arrayDimFetchLoopNativeType, $keyLoopNativeType, $valueTypeChanged, $keyTypeChanged): Type { if ($type instanceof UnionType || $type instanceof IntersectionType) { return $traverse($type); } @@ -1349,7 +1362,10 @@ public function processStmtNode( return $type; } - return new ArrayType($type->getKeyType(), $arrayDimFetchLoopNativeType); + return new ArrayType( + $keyTypeChanged ? $keyLoopNativeType : $type->getKeyType(), + $valueTypeChanged ? $arrayDimFetchLoopNativeType : $type->getIterableValueType(), + ); }); if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-7076.php b/tests/PHPStan/Analyser/nsrt/bug-7076.php new file mode 100644 index 00000000000..c05ead15b45 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7076.php @@ -0,0 +1,202 @@ + $arguments + * @return array + */ +function narrowWithIsString(array $arguments): array +{ + foreach ($arguments as $key => $argument) { + if (!is_string($key)) { + throw new \Exception('Key must be a string'); + } + } + + assertType('array', $arguments); + + return $arguments; +} + +/** + * @param array $arguments + * @return array + */ +function narrowWithIsInt(array $arguments): array +{ + foreach ($arguments as $key => $argument) { + if (is_int($key)) { + throw new \Exception('Key must be a string'); + } + } + + assertType('array', $arguments); + + return $arguments; +} + +/** + * @param array $arguments + */ +function narrowToIntKeys(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (!is_int($key)) { + throw new \Exception('Key must be an int'); + } + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function narrowWithReturn(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (!is_string($key)) { + return; + } + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function continueDoesNotNarrow(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (!is_string($key)) { + continue; + } + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function breakPreventsNarrowing(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (!is_string($key)) { + throw new \Exception(); + } + if (rand(0, 1)) { + break; + } + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function keyAndValueNarrowing(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (!is_string($key)) { + throw new \Exception(); + } + $arguments[$key] = $argument ?? ''; + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function noKeyVar(array $arguments): void +{ + foreach ($arguments as $argument) { + if (!is_string($argument)) { + throw new \Exception(); + } + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function keyReassignedPreventsNarrowing(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + $key = 'test'; + if (!is_string($key)) { + throw new \Exception(); + } + } + + assertType('array', $arguments); +} + +/** + * @param array $arguments + */ +function narrowWithAssert(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + assert(is_string($key)); + } + + assertType('array', $arguments); +} + +/** + * @param non-empty-array $arguments + */ +function narrowNonEmptyArray(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (!is_string($key)) { + throw new \Exception(); + } + } + + assertType('non-empty-array', $arguments); +} + +class Foo +{ + /** @var array */ + private array $prop; + + public function narrowPropertyKey(): void + { + foreach ($this->prop as $k => $v) { + if (!is_string($k)) { + throw new \Exception(); + } + } + + assertType('array', $this->prop); + } +} + +/** + * @param array $arguments + */ +function partialContinueNarrowingDoesNotApply(array $arguments): void +{ + foreach ($arguments as $key => $argument) { + if (rand(0, 1)) { + continue; + } + if (!is_string($key)) { + throw new \Exception(); + } + } + + assertType('array', $arguments); +}