Narrow array key type when removing HasOffsetType via ArrayType::tryRemove#5756
Narrow array key type when removing HasOffsetType via ArrayType::tryRemove#5756phpstan-bot wants to merge 1 commit into
HasOffsetType via ArrayType::tryRemove#5756Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
Is there a way to reproduce the HasOffsetValueType bug with nsrt test ?
|
All tests pass (12160 tests, 79841 assertions) and Now for the reviewer's question: "Is there a way to reproduce the HasOffsetValueType bug with nsrt test?" After thorough investigation, the answer is no. Here's why: The only code path that creates $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())];This always uses The guard is tested via There are no code changes needed for this review comment since no nsrt test can demonstrate the guard behavior. The existing unit tests are the appropriate verification level. |
|
|
||
| if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { | ||
| if ($typeToRemove instanceof HasOffsetValueType | ||
| && !$typeToRemove->getValueType()->isSuperTypeOf($this->getOffsetValueType($typeToRemove->getOffsetType()))->yes()) { |
There was a problem hiding this comment.
What about maybe() ? Should we make the key optional ? Should we tryRemove the intersection ?
4fb71da to
8026862
Compare
…ryRemove` - Add `HasOffsetType` and `HasOffsetValueType` handling to `ArrayType::tryRemove`, calling `unsetOffset()` to narrow the key type when an offset is known to not exist - This mirrors the existing `ConstantArrayType::tryRemove` behavior which already handles both `HasOffsetType` and `HasOffsetValueType` - Fixes `array_key_exists()` falsy branch not narrowing key types on general `ArrayType` (e.g. `array<0|string, mixed>` → `array<string, mixed>`) - Works for both PHP >= 8.0 and PHP < 8.0 code paths, and propagates correctly through `IntersectionType` and `UnionType` via their `tryRemove` delegation
8026862 to
4cdc600
Compare
Summary
When
array_key_exists(0, $arr)returnedfalse, the array key type was not narrowed — e.g.,array<0|string, mixed>remained unchanged instead of becomingarray<string, mixed>. This affected the falsy branch ofarray_key_exists()andkey_exists()checks.Changes
HasOffsetTypeandHasOffsetValueTypehandling toArrayType::tryRemove()insrc/Type/ArrayType.php, which callsunsetOffset()to narrow the key type when an offset is known not to exist.ConstantArrayType::tryRemove()which already handled both types.tests/PHPStan/Analyser/nsrt/bug-9461.phpcovering:array<0|string, mixed>→array<string, mixed>)array<'foo'|int, mixed>→array<int, mixed>)array<int, mixed>→array<int<min, -1>|int<1, max>, mixed>)key_exists()alias (same behavior asarray_key_exists)Root cause
ArrayType::tryRemove()did not handleHasOffsetTyperemoval. Whenarray_key_exists()returnedfalse, theArrayKeyExistsFunctionTypeSpecifyingExtensionspecifiedHasOffsetType($keyType)as a sureNotType (type to remove).TypeCombinator::remove()delegated toArrayType::tryRemove(), which returnednull(no narrowing possible), so the array type was returned unchanged.The extension also had a second narrowing path using
ArrayType(MixedType, MixedType)->unsetOffset($keyType)intersected with the existing type, but this was ineffective becausegetIterableKeyType()normalizesMixedType(including subtracted variants likemixed~0) toBenevolentUnionType(int|string), causingArrayType::isSuperTypeOf()to lose the subtraction information.The fix adds
HasOffsetType/HasOffsetValueTypehandling toArrayType::tryRemove(), which callsunsetOffset()to properly narrow the key type. This approach naturally works for both PHPDoc and native types becauseMutatingScope::removeTypeFromExpression()callsTypeCombinator::remove()separately on each.Analogous cases probed
ConstantArrayType::tryRemove(): Already handlesHasOffsetTypeandHasOffsetValueType— no fix needed.IntersectionType::tryRemove(): DelegatesTypeCombinator::remove()to each component type — works correctly via theArrayTypefix.UnionType::tryRemove(): Delegates to each union member — works correctly.isset/??/empty: Semantically different fromarray_key_exists(they also check for null/falsy values), so key type narrowing viaHasOffsetTyperemoval is not applicable.key_exists: Same extension asarray_key_exists— already handled.Test
Regression test
tests/PHPStan/Analyser/nsrt/bug-9461.phpcovers integer key removal, string key removal, generic key narrowing, and the original issue's full code flow.Fixes phpstan/phpstan#9461