diff --git a/CLAUDE.md b/CLAUDE.md index e33c66a1c2..c96c8954ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,6 +323,10 @@ Many built-in PHP functions need `DynamicFunctionReturnTypeExtension` implementa - Extensions live in `src/Type/Php/` and are registered in `conf/services.neon` - Each reads the argument types from `Scope::getType()` and returns a more precise `Type` +### toArrayKey() and numeric string coercion + +`Type::toArrayKey()` converts a value type to its array key representation. `StringType::toArrayKey()` returns `string`, but PHP casts numeric strings (like `'123'`) to integer keys. `ConstantStringType::toArrayKey()` correctly handles this (e.g. `'1'` → `int(1)`), and `AccessoryNumericStringType::toArrayKey()` returns `int|numeric-string`. However, a general `string` where `isNumericString()` returns `maybe` needs special handling in extensions that create arrays from values — the key type should be `int|string` (array-key), not just `string`. This applies to `array_count_values`, `array_flip`, and similar functions where input values become output keys. + ### Function signature corrections (`src/Reflection/SignatureMap/`) PHPStan maintains its own signature map for built-in PHP functions in `functionMap.php` and delta files. Fixes involve: diff --git a/src/Type/Php/ArrayCountValuesDynamicReturnTypeExtension.php b/src/Type/Php/ArrayCountValuesDynamicReturnTypeExtension.php index 986f299cd8..0983a9678c 100644 --- a/src/Type/Php/ArrayCountValuesDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayCountValuesDynamicReturnTypeExtension.php @@ -54,8 +54,16 @@ public function getTypeFromFunctionCall( continue; } + $keyType = $itemType->toArrayKey(); + + // PHP casts numeric strings to integer keys, so a general string + // that might be numeric should produce int|string keys + if ($itemType->isString()->yes() && $itemType->isNumericString()->maybe()) { + $keyType = TypeCombinator::union($keyType, new IntegerType()); + } + $outputTypes[] = new IntersectionType([ - new ArrayType($itemType->toArrayKey(), IntegerRangeType::fromInterval(1, null)), + new ArrayType($keyType, IntegerRangeType::fromInterval(1, null)), new NonEmptyArrayType(), ]); } diff --git a/tests/PHPStan/Analyser/nsrt/array-count-values.php b/tests/PHPStan/Analyser/nsrt/array-count-values.php index 0be5d86a1c..93c51b80dc 100644 --- a/tests/PHPStan/Analyser/nsrt/array-count-values.php +++ b/tests/PHPStan/Analyser/nsrt/array-count-values.php @@ -25,7 +25,7 @@ function returnsStringOrObjectArray(): array } // Objects are ignored by array_count_values, with a warning emitted. -assertType('non-empty-array>', array_count_values(returnsStringOrObjectArray())); +assertType('non-empty-array>', array_count_values(returnsStringOrObjectArray())); class StringableObject { diff --git a/tests/PHPStan/Analyser/nsrt/bug-12222.php b/tests/PHPStan/Analyser/nsrt/bug-12222.php index 191b8d758a..746280a968 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12222.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12222.php @@ -1,6 +1,6 @@ = 8.1 -namespace Bug12222; +namespace Bug12222Nsrt; use function PHPStan\Testing\assertType; @@ -51,11 +51,11 @@ public function process(array $data): array 'startDate' => $data['startDate'], ]; - assertType('Bug12222\ContractStatus|null', $contract['status']); + assertType('Bug12222Nsrt\ContractStatus|null', $contract['status']); $contract['isActive'] = $contract['status']?->isActive(); - assertType('Bug12222\ContractStatus|null', $contract['status']); + assertType('Bug12222Nsrt\ContractStatus|null', $contract['status']); $contract['isBeingTerminated'] = $contract['status']?->isBeingTerminated(); - assertType('Bug12222\ContractStatus|null', $contract['status']); + assertType('Bug12222Nsrt\ContractStatus|null', $contract['status']); $contract['isTerminated'] = $contract['status']?->isTerminated(); return $contract; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13996.php b/tests/PHPStan/Analyser/nsrt/bug-13996.php new file mode 100644 index 0000000000..66419a3adc --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13996.php @@ -0,0 +1,29 @@ + $strings + */ +function strings(array $strings): void +{ + assertType('non-empty-array>', array_count_values($strings)); +} + +/** + * @param array $ints + */ +function ints(array $ints): void +{ + assertType('non-empty-array>', array_count_values($ints)); +} + +/** + * @param array $mixed + */ +function intOrString(array $mixed): void +{ + assertType('non-empty-array>', array_count_values($mixed)); +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-14097.php b/tests/PHPStan/Rules/Classes/data/bug-14097.php index bd92988c0c..986f708526 100644 --- a/tests/PHPStan/Rules/Classes/data/bug-14097.php +++ b/tests/PHPStan/Rules/Classes/data/bug-14097.php @@ -1,4 +1,4 @@ -= 8.0 namespace Bug14097;