From 9843dd871b5cba9f452c270d55235a914847e207 Mon Sep 17 00:00:00 2001
From: Markus Staab <markus.staab@redaxo.de>
Date: Mon, 23 Sep 2024 11:54:33 +0200
Subject: [PATCH 1/2] Extract `AllowedArrayKeysTypes::narrowOffsetKeyType()` to
 ease re-use

---
 src/Analyser/TypeSpecifier.php             | 44 ++---------------
 src/Rules/Arrays/AllowedArrayKeysTypes.php | 55 ++++++++++++++++++++++
 2 files changed, 58 insertions(+), 41 deletions(-)

diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php
index a5dc7bc6a5..6a40c74454 100644
--- a/src/Analyser/TypeSpecifier.php
+++ b/src/Analyser/TypeSpecifier.php
@@ -28,6 +28,7 @@
 use PHPStan\Reflection\ParametersAcceptorWithPhpDocs;
 use PHPStan\Reflection\ReflectionProvider;
 use PHPStan\Reflection\ResolvedFunctionVariant;
+use PHPStan\Rules\Arrays\AllowedArrayKeysTypes;
 use PHPStan\ShouldNotHappenException;
 use PHPStan\TrinaryLogic;
 use PHPStan\Type\Accessory\AccessoryArrayListType;
@@ -820,47 +821,8 @@ public function specifyTypesInCondition(
 						);
 					} else {
 						$varType = $scope->getType($var->var);
-						if ($varType->isArray()->yes() && !$varType->isIterableAtLeastOnce()->no()) {
-							$varIterableKeyType = $varType->getIterableKeyType();
-
-							if ($varIterableKeyType->isConstantScalarValue()->yes()) {
-								$narrowedKey = TypeCombinator::union(
-									$varIterableKeyType,
-									TypeCombinator::remove($varIterableKeyType->toString(), new ConstantStringType('')),
-								);
-
-								if (!$varType->hasOffsetValueType(new ConstantIntegerType(0))->no()) {
-									$narrowedKey = TypeCombinator::union(
-										$narrowedKey,
-										new ConstantBooleanType(false),
-									);
-								}
-
-								if (!$varType->hasOffsetValueType(new ConstantIntegerType(1))->no()) {
-									$narrowedKey = TypeCombinator::union(
-										$narrowedKey,
-										new ConstantBooleanType(true),
-									);
-								}
-
-								if (!$varType->hasOffsetValueType(new ConstantStringType(''))->no()) {
-									$narrowedKey = TypeCombinator::addNull($narrowedKey);
-								}
-
-								if (!$varIterableKeyType->isNumericString()->no() || !$varIterableKeyType->isInteger()->no()) {
-									$narrowedKey = TypeCombinator::union($narrowedKey, new FloatType());
-								}
-							} else {
-								$narrowedKey = new MixedType(
-									false,
-									new UnionType([
-										new ArrayType(new MixedType(), new MixedType()),
-										new ObjectWithoutClassType(),
-										new ResourceType(),
-									]),
-								);
-							}
-
+						$narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType);
+						if ($narrowedKey !== null) {
 							$types = $types->unionWith(
 								$this->create(
 									$var->dim,
diff --git a/src/Rules/Arrays/AllowedArrayKeysTypes.php b/src/Rules/Arrays/AllowedArrayKeysTypes.php
index 0bc7c1f4c4..eb7de446ad 100644
--- a/src/Rules/Arrays/AllowedArrayKeysTypes.php
+++ b/src/Rules/Arrays/AllowedArrayKeysTypes.php
@@ -2,12 +2,20 @@
 
 namespace PHPStan\Rules\Arrays;
 
+use PHPStan\Type\ArrayType;
 use PHPStan\Type\BooleanType;
+use PHPStan\Type\Constant\ConstantBooleanType;
+use PHPStan\Type\Constant\ConstantIntegerType;
+use PHPStan\Type\Constant\ConstantStringType;
 use PHPStan\Type\FloatType;
 use PHPStan\Type\IntegerType;
+use PHPStan\Type\MixedType;
 use PHPStan\Type\NullType;
+use PHPStan\Type\ObjectWithoutClassType;
+use PHPStan\Type\ResourceType;
 use PHPStan\Type\StringType;
 use PHPStan\Type\Type;
+use PHPStan\Type\TypeCombinator;
 use PHPStan\Type\UnionType;
 
 final class AllowedArrayKeysTypes
@@ -24,4 +32,51 @@ public static function getType(): Type
 		]);
 	}
 
+	public static function narrowOffsetKeyType(Type $varType): ?Type {
+		if (!$varType->isArray()->yes() || $varType->isIterableAtLeastOnce()->no()) {
+			return null;
+		}
+
+		$varIterableKeyType = $varType->getIterableKeyType();
+
+		if ($varIterableKeyType->isConstantScalarValue()->yes()) {
+			$narrowedKey = TypeCombinator::union(
+				$varIterableKeyType,
+				TypeCombinator::remove($varIterableKeyType->toString(), new ConstantStringType('')),
+			);
+
+			if (!$varType->hasOffsetValueType(new ConstantIntegerType(0))->no()) {
+				$narrowedKey = TypeCombinator::union(
+					$narrowedKey,
+					new ConstantBooleanType(false),
+				);
+			}
+
+			if (!$varType->hasOffsetValueType(new ConstantIntegerType(1))->no()) {
+				$narrowedKey = TypeCombinator::union(
+					$narrowedKey,
+					new ConstantBooleanType(true),
+				);
+			}
+
+			if (!$varType->hasOffsetValueType(new ConstantStringType(''))->no()) {
+				$narrowedKey = TypeCombinator::addNull($narrowedKey);
+			}
+
+			if (!$varIterableKeyType->isNumericString()->no() || !$varIterableKeyType->isInteger()->no()) {
+				$narrowedKey = TypeCombinator::union($narrowedKey, new FloatType());
+			}
+		} else {
+			$narrowedKey = new MixedType(
+				false,
+				new UnionType([
+					new ArrayType(new MixedType(), new MixedType()),
+					new ObjectWithoutClassType(),
+					new ResourceType(),
+				]),
+			);
+		}
+
+		return $narrowedKey;
+	}
 }

From 2f2e8429f25c7a93501434c293e08f9778e89f34 Mon Sep 17 00:00:00 2001
From: Markus Staab <markus.staab@redaxo.de>
Date: Mon, 23 Sep 2024 12:31:44 +0200
Subject: [PATCH 2/2] isset() narrows string-key in int-keyed-array to
 numeric-string

---
 src/Analyser/TypeSpecifier.php             |  2 +-
 src/Rules/Arrays/AllowedArrayKeysTypes.php | 26 ++++++++++--------
 tests/PHPStan/Analyser/nsrt/bug-11716.php  | 32 ++++++++++++++++++----
 3 files changed, 43 insertions(+), 17 deletions(-)

diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php
index 6a40c74454..fb8b5985e7 100644
--- a/src/Analyser/TypeSpecifier.php
+++ b/src/Analyser/TypeSpecifier.php
@@ -821,7 +821,7 @@ public function specifyTypesInCondition(
 						);
 					} else {
 						$varType = $scope->getType($var->var);
-						$narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType);
+						$narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType);
 						if ($narrowedKey !== null) {
 							$types = $types->unionWith(
 								$this->create(
diff --git a/src/Rules/Arrays/AllowedArrayKeysTypes.php b/src/Rules/Arrays/AllowedArrayKeysTypes.php
index eb7de446ad..2b15a4eb65 100644
--- a/src/Rules/Arrays/AllowedArrayKeysTypes.php
+++ b/src/Rules/Arrays/AllowedArrayKeysTypes.php
@@ -32,7 +32,8 @@ public static function getType(): Type
 		]);
 	}
 
-	public static function narrowOffsetKeyType(Type $varType): ?Type {
+	public static function narrowOffsetKeyType(Type $varType, Type $keyType): ?Type
+	{
 		if (!$varType->isArray()->yes() || $varType->isIterableAtLeastOnce()->no()) {
 			return null;
 		}
@@ -66,17 +67,20 @@ public static function narrowOffsetKeyType(Type $varType): ?Type {
 			if (!$varIterableKeyType->isNumericString()->no() || !$varIterableKeyType->isInteger()->no()) {
 				$narrowedKey = TypeCombinator::union($narrowedKey, new FloatType());
 			}
-		} else {
-			$narrowedKey = new MixedType(
-				false,
-				new UnionType([
-					new ArrayType(new MixedType(), new MixedType()),
-					new ObjectWithoutClassType(),
-					new ResourceType(),
-				]),
-			);
+
+			return $narrowedKey;
+		} elseif ($varIterableKeyType->isInteger()->yes() && $keyType->isString()->yes()) {
+			return TypeCombinator::intersect($varIterableKeyType->toString(), $keyType);
 		}
 
-		return $narrowedKey;
+		return new MixedType(
+			false,
+			new UnionType([
+				new ArrayType(new MixedType(), new MixedType()),
+				new ObjectWithoutClassType(),
+				new ResourceType(),
+			]),
+		);
 	}
+
 }
diff --git a/tests/PHPStan/Analyser/nsrt/bug-11716.php b/tests/PHPStan/Analyser/nsrt/bug-11716.php
index 2394e8a175..e637669483 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-11716.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-11716.php
@@ -35,9 +35,10 @@ public function parse(string $glue): string
 }
 
 /**
- * @param array<int, string> $arr
+ * @param array<int, string> $intKeyedArr
+ * @param array<string, string> $stringKeyedArr
  */
-function narrowKey($mixed, string $s, int $i, array $generalArr, array $arr): void {
+function narrowKey($mixed, string $s, int $i, array $generalArr, array $intKeyedArr, array $stringKeyedArr): void {
 	if (isset($generalArr[$mixed])) {
 		assertType('mixed~(array|object|resource)', $mixed);
 	} else {
@@ -59,21 +60,42 @@ function narrowKey($mixed, string $s, int $i, array $generalArr, array $arr): vo
 	}
 	assertType('string', $s);
 
-	if (isset($arr[$mixed])) {
+	if (isset($intKeyedArr[$mixed])) {
 		assertType('mixed~(array|object|resource)', $mixed);
 	} else {
 		assertType('mixed', $mixed);
 	}
 	assertType('mixed', $mixed);
 
-	if (isset($arr[$i])) {
+	if (isset($intKeyedArr[$i])) {
 		assertType('int', $i);
 	} else {
 		assertType('int', $i);
 	}
 	assertType('int', $i);
 
-	if (isset($arr[$s])) {
+	if (isset($intKeyedArr[$s])) {
+		assertType("numeric-string", $s);
+	} else {
+		assertType('string', $s);
+	}
+	assertType('string', $s);
+
+	if (isset($stringKeyedArr[$mixed])) {
+		assertType('mixed~(array|object|resource)', $mixed);
+	} else {
+		assertType('mixed', $mixed);
+	}
+	assertType('mixed', $mixed);
+
+	if (isset($stringKeyedArr[$i])) {
+		assertType('int', $i);
+	} else {
+		assertType('int', $i);
+	}
+	assertType('int', $i);
+
+	if (isset($stringKeyedArr[$s])) {
 		assertType('string', $s);
 	} else {
 		assertType('string', $s);