From c6060a35152d0dbc790f6cd7f7e59f9ddf3091ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 26 Mar 2026 11:03:53 +0100 Subject: [PATCH 1/3] reportUnsafeArrayStringKeyCasting - detect implementation --- conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/DependencyInjection/ContainerFactory.php | 1 + ...eportUnsafeArrayStringKeyCastingToggle.php | 34 +++++++ .../AccessoryDecimalIntegerStringType.php | 2 +- src/Type/ArrayType.php | 39 +++++++- ...ringKeyCastingDetectTypeAcceptanceTest.php | 45 +++++++++ ...tringKeyCastingDetectTypeInferenceTest.php | 40 ++++++++ ...ringKeyCastingUnsafeTypeAcceptanceTest.php | 34 +++++++ ...nsafe-array-string-key-casting-accepts.php | 42 +++++++++ ...unsafe-array-string-key-casting-detect.php | 91 +++++++++++++++++++ .../Analyser/nsrt/decimal-int-string.php | 10 ++ ...portUnsafeArrayStringKeyCastingDetect.neon | 2 + 13 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php create mode 100644 tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php create mode 100644 tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php create mode 100644 tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon diff --git a/conf/config.neon b/conf/config.neon index cb8c20ad481..67a5301b571 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -82,6 +82,7 @@ parameters: reportWrongPhpDocTypeInVarTag: false reportAnyTypeWideningInVarTag: false reportNonIntStringArrayKey: false + reportUnsafeArrayStringKeyCasting: null reportPossiblyNonexistentGeneralArrayOffset: false reportPossiblyNonexistentConstantArrayOffset: false checkMissingOverrideMethodAttribute: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index bc79fe7c401..300ea5ac343 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -91,6 +91,7 @@ parametersSchema: reportWrongPhpDocTypeInVarTag: bool() reportAnyTypeWideningInVarTag: bool() reportNonIntStringArrayKey: bool() + reportUnsafeArrayStringKeyCasting: schema(string(), pattern('detect|prevent'), nullable()) reportPossiblyNonexistentGeneralArrayOffset: bool() reportPossiblyNonexistentConstantArrayOffset: bool() checkMissingOverrideMethodAttribute: bool() diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index cac88d0e39b..6e8f0a385e7 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -202,6 +202,7 @@ public static function postInitializeContainer(Container $container): void $container->getService('typeSpecifier'); BleedingEdgeToggle::setBleedingEdge($container->getParameter('featureToggles')['bleedingEdge']); + ReportUnsafeArrayStringKeyCastingToggle::setLevel($container->getParameter('reportUnsafeArrayStringKeyCasting')); } public function getCurrentWorkingDirectory(): string diff --git a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php new file mode 100644 index 00000000000..e2f13563fec --- /dev/null +++ b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php @@ -0,0 +1,34 @@ +inverse) { - return new StringType(); + return $this; } return new IntegerType(); diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 288caefdc63..3a326a13d2b 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -12,6 +13,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -47,6 +49,8 @@ class ArrayType implements Type private Type $keyType; + private ?Type $cachedIterableKeyType = null; + /** @api */ public function __construct(Type $keyType, private Type $itemType) { @@ -198,15 +202,44 @@ public function getArraySize(): Type public function getIterableKeyType(): Type { + if ($this->cachedIterableKeyType !== null) { + return $this->cachedIterableKeyType; + } $keyType = $this->keyType; if ($keyType instanceof MixedType && !$keyType instanceof TemplateMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); } if ($keyType instanceof StrictMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + } + + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level === null) { + return $this->cachedIterableKeyType = $keyType; + } + + if ($level === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this->cachedIterableKeyType = $keyType; + } + + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::DETECT) { // @phpstan-ignore notIdentical.alwaysFalse + throw new ShouldNotHappenException(); } - return $keyType; + return $this->cachedIterableKeyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if ($type->isString()->yes() && !$type->isDecimalIntegerString()->no()) { + return TypeCombinator::union( + new IntegerType(), + TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)), + ); + } + + return $type; + }); } public function getFirstIterableKeyType(): Type diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php new file mode 100644 index 00000000000..02412d59406 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php @@ -0,0 +1,45 @@ + + */ +class ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingDetect.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php new file mode 100644 index 00000000000..1ad96508af6 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingDetect.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php new file mode 100644 index 00000000000..429a6a438ae --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php @@ -0,0 +1,34 @@ + + */ +class ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php new file mode 100644 index 00000000000..d04e42a4990 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php @@ -0,0 +1,42 @@ + $a */ + public function doFoo(array $a): void + { + + } + + /** @param array $a */ + public function doBar(array $a): void + { + + } + + /** @param array $a */ + public function doBaz(array $a): void + { + + } + + public function doTest(string $s): void + { + $a = [$s => new stdClass()]; + $this->doFoo($a); + $this->doBar($a); + $this->doBaz($a); + + $b = []; + $b[$s] = new stdClass(); + $this->doFoo($b); + $this->doBar($b); + $this->doBaz($b); + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php new file mode 100644 index 00000000000..df88c4437db --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php @@ -0,0 +1,91 @@ + $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBar(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class FooNonDecimalIntString +{ + + /** + * @param array $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** @param non-decimal-int-string $s */ + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php index 63747525028..fe5c5fdd529 100644 --- a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -35,6 +35,16 @@ public function doBar(string $s): void assertType('float|int', $s + $s); } + public function doBaz(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + /** * @param non-decimal-int-string $s */ diff --git a/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon new file mode 100644 index 00000000000..1ea800ca917 --- /dev/null +++ b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon @@ -0,0 +1,2 @@ +parameters: + reportUnsafeArrayStringKeyCasting: detect From dc0f4e008ab94462f872474855d7f6630a08090e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 29 Mar 2026 15:07:50 +0200 Subject: [PATCH 2/3] reportUnsafeArrayStringKeyCasting - prevent implementation --- .../ValidateIgnoredErrorsExtension.php | 1 + src/PhpDoc/TypeNodeResolver.php | 31 ++++++- .../Accessory/AccessoryNumericStringType.php | 17 +++- src/Type/ArrayType.php | 9 +- src/Type/StringType.php | 12 ++- ...ingKeyCastingPreventTypeAcceptanceTest.php | 53 +++++++++++ ...ringKeyCastingPreventTypeInferenceTest.php | 40 ++++++++ ...nsafe-array-string-key-casting-prevent.php | 91 +++++++++++++++++++ ...ortUnsafeArrayStringKeyCastingPrevent.neon | 2 + 9 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php create mode 100644 tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php create mode 100644 tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index 6bcd2eb995d..3ee37fc86b6 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -139,6 +139,7 @@ public function getRegistry(): UnaryOperatorTypeSpecifyingExtensionRegistry } }, new OversizedArrayBuilder(), true), + reportUnsafeArrayStringKeyCasting: null, ), ), ); diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 815789377a7..27e7ae27ac9 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -10,7 +10,9 @@ use PhpParser\Node\Name; use PHPStan\Analyser\ConstantResolver; use PHPStan\Analyser\NameScope; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFalseNode; @@ -106,6 +108,7 @@ use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\ValueOfType; @@ -128,6 +131,9 @@ use function strtolower; use function substr; +/** + * @phpstan-import-type Level from ReportUnsafeArrayStringKeyCastingToggle as ReportUnsafeArrayStringKeyCastingLevel + */ #[AutowiredService] final class TypeNodeResolver { @@ -135,12 +141,17 @@ final class TypeNodeResolver /** @var array */ private array $genericTypeResolvingStack = []; + /** + * @param ReportUnsafeArrayStringKeyCastingLevel $reportUnsafeArrayStringKeyCasting + */ public function __construct( private TypeNodeResolverExtensionRegistryProvider $extensionRegistryProvider, private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, private TypeAliasResolverProvider $typeAliasResolverProvider, private ConstantResolver $constantResolver, private InitializerExprTypeResolver $initializerExprTypeResolver, + #[AutowiredParameter] + private ?string $reportUnsafeArrayStringKeyCasting, ) { } @@ -661,7 +672,7 @@ private function resolveConditionalTypeForParameterNode(ConditionalTypeForParame private function resolveArrayTypeNode(ArrayTypeNode $typeNode, NameScope $nameScope): Type { $itemType = $this->resolve($typeNode->type, $nameScope); - return new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $itemType); + return new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $itemType); } private function resolveGenericTypeNode(GenericTypeNode $typeNode, NameScope $nameScope): Type @@ -686,9 +697,23 @@ static function (string $variance): TemplateTypeVariance { if (in_array($mainTypeName, ['array', 'non-empty-array'], true)) { if (count($genericTypes) === 1) { // array - $arrayType = new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $genericTypes[0]); + $arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $keyType = TypeCombinator::intersect($genericTypes[0]->toArrayKey(), new UnionType([ + $originalKey = $genericTypes[0]; + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + $originalKey = TypeTraverser::map($originalKey, static function (Type $type, callable $traverse) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof StringType) { + return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); + } + + return $type; + }); + } + $keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([ new IntegerType(), new StringType(), ]))->toArrayKey(); diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php index 62e320e5de5..d0a7d65ac0d 100644 --- a/src/Type/Accessory/AccessoryNumericStringType.php +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Accessory; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -209,12 +210,20 @@ public function toArray(): Type public function toArrayKey(): Type { + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + new UnionType([ + new IntegerType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + ]); + } + return new UnionType([ new IntegerType(), - new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - ]), + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType(inverse: true)]), ]); } diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 3a326a13d2b..8a82601946f 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -34,6 +34,7 @@ use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; use function array_merge; use function count; +use function in_array; use function sprintf; /** @api */ @@ -54,11 +55,11 @@ class ArrayType implements Type /** @api */ public function __construct(Type $keyType, private Type $itemType) { - if ($keyType->describe(VerbosityLevel::value()) === '(int|string)') { + if (in_array($keyType->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) { $keyType = new MixedType(); } if ($keyType instanceof StrictMixedType && !$keyType instanceof TemplateStrictMixedType) { - $keyType = new UnionType([new StringType(), new IntegerType()]); + $keyType = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); } $this->keyType = $keyType; @@ -207,10 +208,10 @@ public function getIterableKeyType(): Type } $keyType = $this->keyType; if ($keyType instanceof MixedType && !$keyType instanceof TemplateMixedType) { - $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } if ($keyType instanceof StrictMixedType) { - $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 03361300331..afbdcf92a43 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -2,12 +2,14 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ReflectionProviderStaticAccessor; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -177,7 +179,15 @@ public function toArray(): Type public function toArrayKey(): Type { - return $this; + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this; + } + + return new UnionType([ + new IntegerType(), + TypeCombinator::intersect($this, new AccessoryDecimalIntegerStringType(inverse: true)), + ]); } public function toCoercedArgumentType(bool $strictTypes): Type diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php new file mode 100644 index 00000000000..846ab7512a5 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php @@ -0,0 +1,53 @@ + + */ +class ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doFoo() expects array, non-empty-array given.', + 31, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doFoo() expects array, non-empty-array given.', + 37, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingPrevent.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php new file mode 100644 index 00000000000..0f8b64b4b51 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingPrevent.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php new file mode 100644 index 00000000000..163a996bd25 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php @@ -0,0 +1,91 @@ + $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBar(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class FooNonDecimalIntString +{ + + /** + * @param array $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** @param non-decimal-int-string $s */ + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} diff --git a/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon new file mode 100644 index 00000000000..f35820e8667 --- /dev/null +++ b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon @@ -0,0 +1,2 @@ +parameters: + reportUnsafeArrayStringKeyCasting: prevent From ee4255c1aea0b4c714ce0b2a931ce12b16575d5c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 29 Mar 2026 15:43:44 +0200 Subject: [PATCH 3/3] detect as default --- conf/config.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/config.neon b/conf/config.neon index 67a5301b571..b57baff06ad 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -82,7 +82,7 @@ parameters: reportWrongPhpDocTypeInVarTag: false reportAnyTypeWideningInVarTag: false reportNonIntStringArrayKey: false - reportUnsafeArrayStringKeyCasting: null + reportUnsafeArrayStringKeyCasting: detect reportPossiblyNonexistentGeneralArrayOffset: false reportPossiblyNonexistentConstantArrayOffset: false checkMissingOverrideMethodAttribute: false