Narrow the value of an optional array offset on the left of ?? when compared with ===/!== without asserting it exists#6005
Open
phpstan-bot wants to merge 1 commit into
Conversation
… compared with `===`/`!==` without asserting it exists
- In `TypeSpecifier::createForExpr()`, when a coalesce is specified in the
false context and the compared type does not remove the default value
(so the left side may still be absent), narrow the left side's value via
the new `narrowCoalesceLeftPreservingExistence()` helper instead of leaving
it untouched.
- The helper rebuilds the container's constant-array shape with the compared
type removed from the offset's value while keeping the key optional, so
`($a['k'] ?? null) !== false` yields `array{k?: mixed~false}` rather than
`array{k?: mixed}`, and does so for any compared constant (e.g. `!== 2` on
`1|2|3` yields `1|3`).
- Only variable and property/static-property containers are overwritten;
nested offsets (`$a[$x]['k']`) are left alone so the outer offset is not
wrongly memorized as existing (preserves the fix from phpstan#6000).
- Added `ConstantArrayType::replaceOffsetValueTypePreservingOptionality()`
which replaces an existing offset's value without promoting an optional
key to a required one (unlike `setExistingOffsetValueType()`).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
After an early return like
PHPStan used to either memorize
$options['foo']as existing and non-nullable (unsound, fixed earlier in #6000) or, after that fix, drop all value narrowing and inferarray{foo?: mixed}. The correct type isarray{foo?: mixed~false}: the offset may still be absent, but if present it is notfalse.This change restores the value narrowing while keeping it sound — the offset's optional status is preserved and its existence is never asserted.
Changes
src/Analyser/TypeSpecifier.phpcreateForExpr(): split the coalesce false-context handling. When the compared type removes the default value, the left side is guaranteed to be set and is narrowed directly (unchanged behaviour). When the default survives (the left side may still be absent), the new helper narrows the value without asserting existence.narrowCoalesceLeftPreservingExistence(): for an array-offset left side on a safely-overwritable container, it removes the compared type from the offset's value and rebuilds the container type preserving the key's optional status.isSafeCoalesceContainerToOverwrite(): only plain variables and property / static-property chains reaching down to a variable are overwritten. Array offsets and dynamic accesses in the chain are rejected, so a nested offset like$a[$x]['k']is never memorized as existing.src/Type/Constant/ConstantArrayType.phpreplaceOffsetValueTypePreservingOptionality(): replaces an existing offset's value type without promoting an optional key to a required one (contrast withsetExistingOffsetValueType()).phpstan-baseline.neon: bumped oneinstanceof ConstantStringTypebaselined count (5 → 6) for the new key-matching inConstantArrayType, consistent with the siblingunsetOffset()/builder code.Analogous cases handled
=== falseand!== false, in both branches.false(e.g.!== 2onarray{foo?: 1|2|3}→array{foo?: 1|3},=== 0onarray{foo?: int}→array{foo?: int<min,-1>|int<1,max>}).$options['foo']) and property containers ($this->options['foo'],$c->options['foo']).($options['foo'] ?? null) !== null) still narrows to a required, non-null offset.Analogous cases deliberately left unchanged (verified sound)
$rows[$i]['foo']) and general (non-constant) arrays: no narrowing, so existence of a preceding offset is not asserted — this keeps the Do not narrow the left side of??to non-null when the coalesce context still permits a falsey value #6000 fix intact.??left operand): the coalesce expression itself is already narrowed by the existing machinery and forcing existence there could be unsound for possibly-undefined variables, so they are left as-is.Root cause
Writing a narrowed value back to an array offset goes through
MutatingScope::specifyExpressionType(), which intersects the container with aHasOffsetValueType, asserting the offset exists. For a??access that is exactly wrong: the whole expression can still be produced by a missing offset. The previous fix (#6000) avoided the unsoundness by simply not narrowing at all, losing precision.The fix narrows the offset value through a dedicated path that rebuilds the constant-array shape and keeps the key optional, so precision is restored without asserting existence.
Test
tests/PHPStan/Analyser/nsrt/bug-12057.phpcovers the reported early-return case, both===/!==branches, union-value narrowing, non-falsecompared constants, a property-container variant, the default-eliminated case, and — as a guard against regressing Do not narrow the left side of??to non-null when the coalesce context still permits a falsey value #6000 — a nested-offset case that must stayarray<string, array{foo?: mixed}>.2.2.xbefore the fix and pass after it.make testsandmake phpstanare green.Fixes phpstan/phpstan#12057