An optional non-list key makes isList Maybe, not No - #6025
Conversation
e27c6d8 to
9fa048d
Compare
| public function narrowing(array $optStr): void | ||
| { | ||
| if (array_is_list($optStr)) { | ||
| assertType('list{}&list', $optStr); |
There was a problem hiding this comment.
Before this fix array{a?: string} had isList = No, so this branch was *NEVER* (unreachable). With isList now Maybe, the empty-array realisation survives the is list intersection, so the branch is reachable and narrows to the empty list.
| } else { | ||
| // array_is_list()'s false branch is not narrowed, so `a` stays optional | ||
| // here rather than being refined to array{a: string}. | ||
| assertType('array{a?: string}', $optStr); |
There was a problem hiding this comment.
The a? here (rather than array{a: string}) is pre-existing and unrelated to this fix: array_is_list()'s false branch is not refined. It stays a? for sealed shapes too — e.g. even array{0: string, a?: string} keeps a? in the false branch on current stable.
|
Update: I folded the The only remaining difference between the two PRs is the projection of an invalid sealed list-shape in |
|
This pull request has been marked as ready for review. |
702067f to
25d81d1
Compare
e3e6293 to
291f84b
Compare
|
This pull request has been marked as ready for review. |
| $existsInvalid = false; | ||
|
|
||
| foreach ($keyTypes as $i => $keyType) { | ||
| $isOptional = array_key_exists($i, $optional); |
There was a problem hiding this comment.
this line makes me feel that we might need to handle shapes with a key which php auto converts to int (e.g. "1").
please add a test for such example. usually a fix would need toArrayKey()
There was a problem hiding this comment.
Good catch — fixed. inferIsListFromShape() now normalizes each key with toArrayKey() before deciding whether it continues the list, so a numeric-string key like "1" (which PHP stores as the integer key 1) is treated as an integer.
In practice keys reaching this method are already normalized (the builder calls toArrayKey() in setOffsetValueType(), and PHPDoc array{"1": …} resolves to array{1: …}), so it's a defensive fix — but a directly-constructed ConstantArrayType can carry an un-normalized "1" key, which is what the new test testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness exercises: merging array{0: string, '1'?: string} yields isList = yes (it fails without the toArrayKey() call, giving maybe).
There was a problem hiding this comment.
Followed up: the same instanceof ConstantIntegerType pattern in the adjacent isListAfterUnset() (the preserveListCertainty path of unsetOffset()) had the same numeric-string-key gap, so I normalized there too — matching the two fixes #6026 landed for this. Added testUnsetOffsetTreatsNumericStringKeyAsIntWhenRecomputingListness (unsetting the optional tail of list{'0': string, 1?: int} keeps isList = yes; it regresses to a broken result without the toArrayKey() call).
291f84b to
4995df0
Compare
A ConstantArrayType whose only list-incompatible keys are optional can
still be a list when those keys are absent (`array{a?: string}` admits
`[]`), so its isList must be Maybe, not No. Previously any string /
negative / gap key forced No regardless of optionality, which made
`array_is_list()` report "always false" on such shapes.
- ConstantArrayTypeBuilder: an optional list-incompatible key degrades
isList Yes to Maybe (No stays No) via markNonListKey().
- ArrayType::isSuperTypeOf(): a possibly-empty constant array always
admits `[]`, a subtype of every array type, so the relationship is at
worst Maybe — never a definite No. This lets the `($value is list)`
conditional of array_is_list() resolve to bool for possibly-empty
shapes instead of false.
- ConstantArrayType::makeList(): now that gap/string optional keys yield
isList Maybe, intersecting a sealed such shape with list keeps only the
contiguous 0..m prefix (the keys that can actually appear in a list)
instead of collapsing to *NEVER*.
Closes phpstan/phpstan#14938
4995df0 to
91450e0
Compare
A
ConstantArrayTypewhose only list-incompatible keys are optional can still be a list when those keys are absent —array{a?: string}admits[], andarray_is_list([]) === true. So itsisListmust be Maybe, not No. Previously any string / negative / gap key forcedisList = Noregardless of optionality, soarray_is_list()was reported as always false on such shapes.This is the dual of #12725 (there, shapes that also admit non-list values wrongly report
isListyes; here, shapes that also admit list values wrongly report no).Parts:
ConstantArrayTypeBuilder— an optional list-incompatible key degradesisListYes→Maybe (No stays No) via a newmarkNonListKey(), replacing four unconditionalcreateNo()sites.ArrayType::isSuperTypeOf()— a possibly-empty constant array always admits[], which is a subtype of every array type, so the relationship is at worst Maybe, never a definite No. This lets the($value is list ? true : false)conditional ofarray_is_list()resolve toboolfor possibly-empty shapes (e.g.array{a?: string}) instead offalse.ConstantArrayType::makeList()— now that gap/string optional keys yieldisListMaybe, intersecting a sealed such shape withlistkeeps only the contiguous0..mprefix (the keys that can actually appear in a list) rather than collapsing to*NEVER*. Unsealed extras may fill the gaps, so there every key is kept.ConstantArrayType::mergeWith()/legacyMergeWith()— merging widens keys present in only one side into optional keys, so the result can admit list realizations neither input did (array{a: 1}|array{b: 2}→array{a?: 1, b?: 2}admits[]). The list-ness of a sealed merged shape is now recomputed from the shape viainferIsListFromShape()instead of the too-strict$this->isList->and($otherArray->isList); two pure lists still merge into a list. This is the same merge-side fix as DegradeisListtomaybefor optional non-list keys in array shapes and shape merges #6026, folded in here — thanks to the parallel analysis there. (The one deliberate difference from DegradeisListtomaybefor optional non-list keys in array shapes and shape merges #6026 ismakeList()above: this PR projects invalid sealed list-shapes to their valid form, e.g.list{0: string, 1: int, 2?: string, 4?: string}→list{0: string, 1: int, 2?: string}, rather than keeping the unreachable key; see the tradeoff note on DegradeisListtomaybefor optional non-list keys in array shapes and shape merges #6026.)The existing
array-shape-list-optional.phpexpectations change accordingly: an invalid sealed list-shape likelist{0: string, 1: int, 2?: string, 4?: string}now resolves to its valid projectionarray{0: string, 1: int, 2?: string}.Closes phpstan/phpstan#14938