Normalize array keys through toArrayKey() in array_fill_keys and array_combine return types - #6078
phpstan-bot wants to merge 3 commits into
Conversation
…`array_combine` return types - `ArrayType::fillKeysArray()` now passes the resulting key type through `toArrayKey()`, so `decimal-int-string` (and other integer-like string keys) collapse to `int`, matching how PHP coerces array keys. This mirrors what `ArrayType::flipArray()` already does. - `MixedType::fillKeysArray()` normalizes the key type the same way, so `array_fill_keys` on a `mixed` array no longer yields a `mixed` key type (array keys can only be `int|string`). - `ArrayCombineHelper` (the non-constant path of `array_combine`) had the identical missing normalization and is fixed the same way. - Probed the other value-as-key functions: `array_flip`, `array_count_values` and `array_column` already normalize via `toArrayKey()` and were correct; no change needed there. - Updated existing `array-fill-keys` / `array-combine` expectations that encoded the un-normalized keys (float keys now include `int`, `bool` keys collapse to `int|string`, decimal-int-string keys collapse to `int`).
VincentLanglet
left a comment
There was a problem hiding this comment.
Update array-fill-keys-php7.php too
Mirrors the array-fill-keys-php8.php change: after routing the key type through toArrayKey(), array_fill_keys() on a plain array yields array<int|string, 'b'> instead of array<mixed, 'b'>. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Done — pushed as Change: I also checked Verification:
One caveat: the php7 file carries |
| $b = ['apple', 'banana']; | ||
| } | ||
|
|
||
| assertType("non-empty-array<''|'1'|'2', 'apple'|'avocado'|'banana'>", array_combine($a, $b)); |
There was a problem hiding this comment.
Do we have more tests in the codebase with constant int-strings in array keys?
If so, these would need a similar fix?
There was a problem hiding this comment.
Pushed as c03c891f7.
Answer to the review question
No other tests need a fix. I audited this two ways:
1. Mechanical sweep of every test expectation. I wrote a parser that extracts array<…> / non-empty-array<…> / iterable<…> generics from all test files, splits the top-level type arguments, and flags any key position containing a quoted decimal-int string — plus a grep for the constant-array-shape form (array{'1': …}). Validated it against the pre-fix tree: it found exactly the 2 lines this PR already changed (array-combine-php8.php:113 and :126). On the current tree: 0 hits. So those two were the only affected expectations in the repo.
2. Empirical probe of the source paths (in case a path had no test at all). I dumped types for every array function that turns values into keys, plus the other ways an int-string key can enter a type, with both constant '1'|'2' and non-constant numeric-string inputs. All already normalize:
| path | result |
|---|---|
array_fill_keys, array_combine, array_flip, array_count_values |
✅ (this PR + existing toArrayKey()) |
array_column (index_key) |
✅ via castToArrayKeyType() |
iterator_to_array |
✅ explicit toArrayKey() |
key-preserving fns (array_merge/_replace/_filter/_slice/_chunk/_unique/_reverse/_diff_key/_pad, +) |
✅ keys inherited already-normalized |
PHPDoc array{'1': string}, array<'1'|'2', string>, unsealed tails |
✅ TypeNodeResolver |
literals, const, class constants, foreach key, array_key_first/last |
✅ ConstantArrayTypeBuilder::setOffsetValueType() normalizes unconditionally |
The one real gap I found is ConstantArrayTypeBuilder::makeUnsealed(), which does not normalize — callers must. I checked all 30 call sites; every value-derived one (ArrayCombineHelper:82, ConstantArrayType::fillKeysArray/flipArray) normalizes, the rest propagate already-normalized keys.
Added coverage
Since the sweep showed the constant-int-string shapes were only covered incidentally, I pinned them explicitly. Verified they fail on the pre-fix tree for the right reason (array<'1'|'2', 'b'> → array<1|2, 'b'>), so they're genuine guards, not just green tests. array_flip had no int-string coverage at all despite ArrayType::flipArray() already normalizing — added it so the sibling path is locked in too.
make tests: 17608 tests OK (97 skipped). make phpstan: no errors.
Separate bug found, deliberately not in this PR
array_flip() ignores float and bool values at runtime (warns, skips), but PHPStan models them as keys:
array_flip([1.5]) // runtime: [] PHPStan: array{1: 0}
array_flip([true]) // runtime: [] PHPStan: array{1: 0}array-count-values.php already documents and handles exactly this (array{} for [true, 1.0, false, 0.0, null]); flipArray() never got the equivalent. It's a different defect from key normalization, so I left this PR focused — happy to open it separately.
…p` and `array_combine` The normalized-key expectations added so far only exercised `numeric-string` / `decimal-int-string` item types. Pin the constant-int-string shapes too, across the constant-array, general-array and unsealed paths: - `array_fill_keys()` / `array_combine()` on `list<'1'|'2'>` regress to `array<'1'|'2', ...>` without the `toArrayKey()` calls, so these are real guards for that fix. - `array_flip()` had no int-string coverage at all even though `ArrayType::flipArray()` already normalized; the assertions lock in the sibling path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Documenting an array key as
int|decimal-int-stringnormalizes toint, butarray_fill_keys()did not perform this normalization on its result, producingarray<int|decimal-int-string, ...>. Assigning that to anarray<int, ...>property triggered a false positive. The fix runs the computed key type throughType::toArrayKey(), which coerces integer-like string keys tointexactly like PHP does at runtime.Changes
src/Type/ArrayType.php—fillKeysArray()now returnsnew ArrayType($keyType->toArrayKey(), $valueType)in both branches, so the resulting key type is coerced the way PHP coerces array keys (this matchesflipArray(), which already usedtoArrayKey()).src/Type/MixedType.php—fillKeysArray()normalizes the key type throughtoArrayKey()as well, soarray_fill_keys()on amixedarray produces anint|stringkey instead ofmixed.src/Type/Php/ArrayCombineHelper.php— the non-constantarray_combine()path had the same missing normalization; the key type is now passed throughtoArrayKey().array-fill-keys.php,array-fill-keys-php8.phpandarray-combine-php8.phpthat asserted the previous, un-normalized keys.tests/PHPStan/Analyser/nsrt/bug-14980.php.Root cause
array_fill_keys()(andarray_combine(), andarray_fill_keys()onmixed) use the input array's values as keys. PHP coerces those values with the usual array-key rules (decimal-int-strings becomeint, floats/bools/objects are stringified first, then re-coerced). PHPStan'sType::toArrayKey()models exactly this coercion, andarray_flip()/array_count_values()/array_column()already used it — but thearray_fill_keysand non-constantarray_combinepaths built the key type withtoString()(or returned it verbatim) and never appliedtoArrayKey(). As a result the refinement (decimal-int-string, plainmixed, un-truncated floats,bool) leaked into the key type. The pattern is "value-as-key array functions must normalize the key throughtoArrayKey()"; every affected location now does.Test
bug-14980.phpassertsarray_fill_keys()onlist<int|decimal-int-string>yieldsarray<int, true>(the reported case), plus the numeric-string,mixed-array andarray_combineanalogues.array-fill-keys/array-combinefixtures were updated to the now-correct normalized keys (float keys includeint,boolkeys collapse toint|string, decimal-int-string keys collapse toint).Fixes phpstan/phpstan#14980
Closes #6077