Skip to content

Write the offset instead of intersecting with HasOffsetValueType when overwriting an ArrayDimFetch - #6456

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-61aplko
Sep 17, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-61aplko

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

An array literal holding a by-reference item ($a = [1, 2, &$x]) turned into *NEVER* as soon as $x was reassigned, provided $x had a constant type:

$x = 0;
$a = [1, 2, &$x];
$x = 5;
\PHPStan\dumpType($a); // *NEVER*, expected array{1, 2, 5}

Everything read back from $a was *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.php
    • New overwriteExpression(): for an ArrayDimFetch target it writes the value into the containing array with setExistingOffsetValueType() (recursively up the dim chain), then records the slot's own type; for any other target it delegates to assignExpression() unchanged.
    • assignVariable() now uses it when a IntertwinedVariableByReferenceWithExpr link propagates a variable's new value into a linked slot.
    • applySpecifiedTypes() now uses it on the SpecifiedTypes::shouldOverwrite() branch.
  • tests/PHPStan/Analyser/nsrt/bug-15246.php (new) and tests/PHPStan/Analyser/nsrt/set-type-type-specifying.php.

Analogous cases probed:

  • Fixed — every constant flavour on the by-reference axis: int, string, float, bool, array, null → object, a finite 0|1 union and a widened 'a'|'b'; string keys; nested ([[1, &$x]]) and 3-level nested literals; writes inside an if (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.
  • Fixed — 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.
  • Already correct, no test kept — by-reference parameters (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 other setAlwaysOverwriteTypes() extension that can target an offset).
  • Out of scope — $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 an ArrayDimFetch with a constant dim as a narrowing: besides recording the slot's type it intersects the parent with HasOffsetValueType(dim, newType). That is correct for if ($a[2] === 5), but both of these call sites are writes:

  1. assignVariable() propagating a by-reference link ($a = [1, 2, &$x] links $x with $a[2]) — array{1, 2, 0} & hasOffsetValue(2, 5) is never.
  2. applySpecifiedTypes() on SpecifiedTypes::shouldOverwrite(), the flag whose whole purpose is to say "this replaces the old type rather than refining it" — array{x: 0} & hasOffsetValue(x, '0') is never.

Both now route through overwriteExpression(), which sets the offset on the parent instead. setExistingOffsetValueType() rather than setOffsetValueType() 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 keeps list<mixed> a list in bug-13809.php's bar2(), 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 new doArrayOffset() covering settype() on plain, nested and every-cast-target array offsets; three of its assertions report *NEVER* without the fix.

Fixes phpstan/phpstan#15246

…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.
@ondrejmirtes
ondrejmirtes merged commit ee534f4 into phpstan:2.2.x Sep 17, 2026
859 of 890 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-61aplko branch September 17, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants