Repository navigation
Write the offset instead of intersecting with HasOffsetValueType when overwriting an ArrayDimFetch - #6456
Merged
ondrejmirtes merged 1 commit intoSep 17, 2026
Conversation
…en overwriting an `ArrayDimFetch`
- Add `MutatingScope::overwriteExpression()`, which for an `ArrayDimFetch` target
writes the new value into the containing array via `setExistingOffsetValueType()`
on the parent chain, and only falls back to `assignExpression()` for other targets.
- Use it when a by-reference link created for an array literal item (`$a = [1, 2, &$x]`)
propagates `$x`'s new value into `$a[2]`. `assignExpression()` went through
`specifyExpressionType()`, which intersects the parent with
`HasOffsetValueType(2, 5)`; against `array{1, 2, 0}` that is `never`, so the whole
array - and everything read from it - became `*NEVER*`.
- Use it for the same reason in `applySpecifiedTypes()` on the
`SpecifiedTypes::shouldOverwrite()` branch, which fixes `settype($a['x'], 'string')`
collapsing a constant array to `*NEVER*`.
- `setExistingOffsetValueType()` (not `setOffsetValueType()`) is the right operation
here: a byref alias or an overwrite always targets a slot that already exists, so
list-ness and non-emptiness of the parent must be preserved.
- Probed and found already correct: `$ref = &$a[2]` and `$b = &$a['k']` are not linked
at all (phpstan/phpstan#14503, phpstan/phpstan#13967 - a different construct);
by-reference parameters (`preg_match`, `sort`, `sscanf`, user functions),
`list()` destructuring into dims, compound assignment, `++`, `foreach ($src as $a['k'])`,
`$a['k'] = &$x` and foreach-by-reference over a nested array all write correctly.
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
An array literal holding a by-reference item (
$a = [1, 2, &$x]) turned into*NEVER*as soon as$xwas reassigned, provided$xhad a constant type:Everything read back from
$awas*NEVER*too, so no rule had anything to check against. With a non-constant$x(e.g.int) the bug was invisible.Changes
src/Analyser/MutatingScope.phpoverwriteExpression(): for anArrayDimFetchtarget it writes the value into the containing array withsetExistingOffsetValueType()(recursively up the dim chain), then records the slot's own type; for any other target it delegates toassignExpression()unchanged.assignVariable()now uses it when aIntertwinedVariableByReferenceWithExprlink propagates a variable's new value into a linked slot.applySpecifiedTypes()now uses it on theSpecifiedTypes::shouldOverwrite()branch.tests/PHPStan/Analyser/nsrt/bug-15246.php(new) andtests/PHPStan/Analyser/nsrt/set-type-type-specifying.php.Analogous cases probed:
int,string,float,bool,array,null → object, a finite0|1union and a widened'a'|'b'; string keys; nested ([[1, &$x]]) and 3-level nested literals; writes inside anif(array{1, 0|5}) and inside a loop (array{int<0, 2>}); a literal whose earlier explicit key makes the by-reference slot's index non-constant; a slot overwritten through the array first; the same variable referenced from two literals or twice in one literal; a reference chained through$y = &$x.settype()on an array offset (settype($a['x'], 'string'), nested offsets, and every cast target) previously produced*NEVER*through the exact same mechanism, one code path further out.preg_match,sort,array_multisort,sscanf,str_replace's$count, user functions with&$p) writing into an array offset;list()destructuring into offsets;.=,++;foreach ($src as $a['k']);$a['k'] = &$x;foreach ($outer['k'] as &$v);ReflectionClass::isSubclassOf()(the othersetAlwaysOverwriteTypes()extension that can target an offset).$ref = &$a[2]/$b = &$a['k']create no link at all (References to components of arrays don't appears to be understood by phpstan phpstan#14503, Array modifications on a reference variable are not tracked properly phpstan#13967), and$this->a = [1, &$x]/$outer['k'] = [1, &$x]are not linked either because the by-reference item bookkeeping only runs for a plain variable target. All of these give a stale type, not*NEVER*, so they are a separate missing-feature axis rather than this bug.Root cause
The pattern is a write expressed as a narrowing.
MutatingScope::specifyExpressionTypeInPlace()treats anArrayDimFetchwith a constant dim as a narrowing: besides recording the slot's type it intersects the parent withHasOffsetValueType(dim, newType). That is correct forif ($a[2] === 5), but both of these call sites are writes:assignVariable()propagating a by-reference link ($a = [1, 2, &$x]links$xwith$a[2]) —array{1, 2, 0} & hasOffsetValue(2, 5)isnever.applySpecifiedTypes()onSpecifiedTypes::shouldOverwrite(), the flag whose whole purpose is to say "this replaces the old type rather than refining it" —array{x: 0} & hasOffsetValue(x, '0')isnever.Both now route through
overwriteExpression(), which sets the offset on the parent instead.setExistingOffsetValueType()rather thansetOffsetValueType()is used deliberately: an alias or overwrite always points at a slot that already exists, so the parent keeps its list-ness and emptiness (this is what keepslist<mixed>a list inbug-13809.php'sbar2(), where the linked dim is a non-constant list key).Test
tests/PHPStan/Analyser/nsrt/bug-15246.php— the issue's reproducer verbatim (both the constant and the non-constant function), plus the analogous by-reference cases listed above. Without the fix 10 of its assertions report*NEVER*(or*ERROR*when reading an offset back).tests/PHPStan/Analyser/nsrt/set-type-type-specifying.php— a newdoArrayOffset()coveringsettype()on plain, nested and every-cast-target array offsets; three of its assertions report*NEVER*without the fix.Fixes phpstan/phpstan#15246