Skip to content

Commit 96f72e6

Browse files
zonuexeclaude
andcommitted
Cast array keys that are handed back as values
PHP casts a decimal-integer string array key ("123") to int, so `array_key_first([$string => null])` is not necessarily a string. PHPStan inferred `string` for it and reported `is_int()` on the result as always false. array_key_first() and array_key_last() went first; call castReadKeyType() from the rest of the accessors that hand a key back as a value of its own: array_keys(), key(), array_find_key(), array_search() and the values of array_flip(). `foreach` keys stay unwidened with the toggle off: the key usually goes straight back into another array, and a benevolent `(int|string)` key collapses that array to `array<mixed, …>`. `detect` remains the level with accurate `foreach` keys. The rename in OptimizedDirectorySourceLocatorFactory is fallout: with the wider `array_keys()` type PHPStan now proves `$file` defined inside `if ($findInFiles !== [])`, which is correct and makes strict-rules flag the reuse. The `@var int|string` workarounds over `key([$string => null])` in ArgumentsNormalizer and ConstantStringType are what this bug looks like from the inside; both are redundant now and go away with their baseline entries. Closes phpstan/phpstan#15073 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b6839d2 commit 96f72e6

22 files changed

Lines changed: 253 additions & 67 deletions

‎phpstan-baseline.neon‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ parameters:
1212
count: 1
1313
path: src/Analyser/AnalyserResultFinalizer.php
1414

15-
-
16-
rawMessage: PHPDoc tag @var with type int|string is not subtype of type string.
17-
identifier: varTag.type
18-
count: 1
19-
path: src/Analyser/ArgumentsNormalizer.php
20-
2115
-
2216
rawMessage: Casting to string something that's already string.
2317
identifier: cast.useless
@@ -1053,12 +1047,6 @@ parameters:
10531047
count: 1
10541048
path: src/Type/Constant/ConstantStringType.php
10551049

1056-
-
1057-
rawMessage: PHPDoc tag @var with type int|string is not subtype of type string.
1058-
identifier: varTag.type
1059-
count: 1
1060-
path: src/Type/Constant/ConstantStringType.php
1061-
10621050
-
10631051
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
10641052
identifier: phpstanApi.instanceofType

‎src/Analyser/ArgumentsNormalizer.php‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ public static function reorderCallUserFuncArrayArguments(
149149
foreach ($argsArrayArg->value->items as $item) {
150150
$key = null;
151151
if ($item->key instanceof String_) {
152-
/** @var int|string $key */
153152
$key = key([$item->key->value => null]);
154153
if ($key === '') {
155154
return null;

‎src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,9 @@ private function createCachedDirectorySourceLocator(array $fileHashes, string $c
213213

214214
if ($findInFiles !== []) {
215215
$cacheModified = true;
216-
foreach ($this->symbolFinderInFiles->findSymbols($findInFiles, $this->phpVersion->supportsEnums()) as $file => [$newClasses, $newFunctions, $newConstants]) {
217-
$newHash = $originalFileHashes[$file];
218-
$cached[$file] = [$newHash, $newClasses, $newFunctions, $newConstants];
216+
foreach ($this->symbolFinderInFiles->findSymbols($findInFiles, $this->phpVersion->supportsEnums()) as $scannedFile => [$newClasses, $newFunctions, $newConstants]) {
217+
$newHash = $originalFileHashes[$scannedFile];
218+
$cached[$scannedFile] = [$newHash, $newClasses, $newFunctions, $newConstants];
219219
}
220220
}
221221

‎src/Type/ArrayType.php‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict
222222

223223
public function getKeysArray(): Type
224224
{
225-
return TypeCombinator::intersect(new self(new IntegerType(), $this->getIterableKeyType()), new AccessoryArrayListType());
225+
return TypeCombinator::intersect(new self(new IntegerType(), UnsafeArrayStringKeyCastingTraverser::castReadKeyType($this->getIterableKeyType())), new AccessoryArrayListType());
226226
}
227227

228228
public function getValuesArray(): Type
@@ -505,7 +505,8 @@ public function fillKeysArray(Type $valueType): Type
505505

506506
public function flipArray(): Type
507507
{
508-
return new self($this->getIterableValueType()->toArrayKey(), $this->getIterableKeyType());
508+
// The keys become values, so they're subject to PHP's array key cast.
509+
return new self($this->getIterableValueType()->toArrayKey(), UnsafeArrayStringKeyCastingTraverser::castReadKeyType($this->getIterableKeyType()));
509510
}
510511

511512
public function intersectKeyArray(Type $otherArraysType): Type
@@ -587,7 +588,7 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ
587588
return new ConstantBooleanType(false);
588589
}
589590

590-
return TypeCombinator::union($this->getIterableKeyType(), new ConstantBooleanType(false));
591+
return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($this->getIterableKeyType(), new ConstantBooleanType(false));
591592
}
592593

593594
public function shiftArray(): Type

‎src/Type/Constant/ConstantArrayType.php‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,15 +1602,16 @@ public function flipArray(): Type
16021602
$offsetType = $valueType->toArrayKey();
16031603
$builder->setOffsetValueType(
16041604
$offsetType,
1605-
$keyType,
1605+
// The keys become values, so they're subject to PHP's array key cast.
1606+
UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType),
16061607
$this->isOptionalKey($i) || count($offsetType->getConstantScalarTypes()) > 1,
16071608
);
16081609
}
16091610

16101611
if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
16111612
[$unsealedKey, $unsealedValue] = $this->unsealed;
16121613
$flippedKey = $unsealedValue->toArrayKey();
1613-
$flippedValue = $unsealedKey;
1614+
$flippedValue = UnsafeArrayStringKeyCastingTraverser::castReadKeyType($unsealedKey);
16141615
// For a non-finite tail key (e.g. `string`), install the
16151616
// unsealed extras first; setOffsetValueType then widens any
16161617
// overlapping explicit values with the tail's value type.
@@ -1731,11 +1732,14 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ
17311732
}
17321733

17331734
if (count($matches) > 0) {
1735+
// The found key becomes a value of its own, so it's subject to PHP's
1736+
// array key cast.
1737+
$matchedKeyType = TypeCombinator::union(...$matches);
17341738
if ($hasIdenticalValue) {
1735-
return TypeCombinator::union(...$matches);
1739+
return UnsafeArrayStringKeyCastingTraverser::castReadKeyType($matchedKeyType);
17361740
}
17371741

1738-
return TypeCombinator::union(new ConstantBooleanType(false), ...$matches);
1742+
return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($matchedKeyType, new ConstantBooleanType(false));
17391743
}
17401744

17411745
return new ConstantBooleanType(false);
@@ -2443,7 +2447,7 @@ private function degradeToGeneralArray(): Type
24432447

24442448
public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type
24452449
{
2446-
$keysArray = $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null);
2450+
$keysArray = $this->getReadKeysArray();
24472451

24482452
return new IntersectionType([
24492453
new ArrayType(
@@ -2456,7 +2460,24 @@ public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict
24562460

24572461
public function getKeysArray(): self
24582462
{
2459-
return $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null);
2463+
return $this->getReadKeysArray();
2464+
}
2465+
2466+
/**
2467+
* The keys as a list of values - they've left the array, so they're subject
2468+
* to PHP's array key cast.
2469+
*/
2470+
private function getReadKeysArray(): self
2471+
{
2472+
$unsealedKeyType = $this->unsealed[0] ?? null;
2473+
2474+
return $this->getKeysOrValuesArray(
2475+
array_map(
2476+
static fn (Type $keyType): Type => UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType),
2477+
$this->keyTypes,
2478+
),
2479+
$unsealedKeyType !== null ? UnsafeArrayStringKeyCastingTraverser::castReadKeyType($unsealedKeyType) : null,
2480+
);
24602481
}
24612482

24622483
public function getValuesArray(): self

‎src/Type/Constant/ConstantStringType.php‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,6 @@ public function toArrayKey(): Type
385385
return $this->arrayKeyType;
386386
}
387387

388-
/** @var int|string $offsetValue */
389388
$offsetValue = key([$this->value => null]);
390389

391390
if ($offsetValue === $this->value) {

‎src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
use PHPStan\Reflection\FunctionReflection;
99
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
1010
use PHPStan\Type\NullType;
11+
use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser;
1112
use PHPStan\Type\Type;
12-
use PHPStan\Type\TypeCombinator;
1313
use function count;
1414

1515
#[AutowiredService]
@@ -33,7 +33,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
3333
return null;
3434
}
3535

36-
return TypeCombinator::union($arrayType->getIterableKeyType(), new NullType());
36+
return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($arrayType->getIterableKeyType(), new NullType());
3737
}
3838

3939
}

‎src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
use PHPStan\Reflection\FunctionReflection;
99
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
1010
use PHPStan\Type\NullType;
11+
use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser;
1112
use PHPStan\Type\Type;
12-
use PHPStan\Type\TypeCombinator;
1313

1414
#[AutowiredService]
1515
final class ArrayKeyDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
@@ -35,10 +35,10 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
3535

3636
$keyType = $argType->getIterableKeyType();
3737
if ($iterableAtLeastOnce->yes()) {
38-
return $keyType;
38+
return UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType);
3939
}
4040

41-
return TypeCombinator::union($keyType, new NullType());
41+
return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($keyType, new NullType());
4242
}
4343

4444
}

‎tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,25 @@ public function doBaz(array $a): void
127127
}
128128

129129
}
130+
131+
class ReadKeys
132+
{
133+
134+
/**
135+
* @param array<string, int> $a
136+
* @param non-empty-array<string, int> $b
137+
*/
138+
public function doFoo(array $a, array $b, string $s): void
139+
{
140+
assertType('int|non-decimal-int-string', array_key_first([$s => null]));
141+
assertType('int|non-decimal-int-string', array_key_last([$s => null]));
142+
assertType('non-empty-list<int|non-decimal-int-string>', array_keys([$s => null]));
143+
144+
assertType('int|non-decimal-int-string|null', array_key_first($a));
145+
assertType('int|non-decimal-int-string', array_key_last($b));
146+
assertType('list<int|non-decimal-int-string>', array_keys($a));
147+
assertType('int|non-decimal-int-string|null', key($a));
148+
assertType('int|non-decimal-int-string|false', array_search(1, $a, true));
149+
}
150+
151+
}

‎tests/PHPStan/Analyser/nsrt/array-find-key.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ function array_find_key(array $array, callable $callback)
3333
*/
3434
function testMixed(array $array, callable $callback): void
3535
{
36-
assertType('int|string|null', array_find_key($array, $callback));
37-
assertType('int|string|null', array_find_key($array, 'is_int'));
36+
assertType('(int|string|null)', array_find_key($array, $callback));
37+
assertType('(int|string|null)', array_find_key($array, 'is_int'));
3838
}
3939

4040
/**

0 commit comments

Comments
 (0)