Skip to content

Use and update scope-tracked chain links when unset() rewrites a nested offset, and keep maybe-missing offsets optional - #6780

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-umej6gn
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-umej6gn

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

After unset($export[$key]['x']) with a non-constant $key, PHPStan forgot that $export[$key] had been narrowed to an array (by is_array() / isset()), so it became mixed. With a constant key it worked, because the narrowing then lives in the root array's shape. This PR makes the virtual write that unset() performs on the parent chain use and update the scope-tracked chain links. It also fixes a related, pre-existing bug: unsetting through an optional key made that key required.

Changes

  • src/Analyser/ExprHandler/AssignHandler.php, KIND_EXISTING_ARRAY_DIM_FETCH branch of applyWrite() (only reached from UnsetHandler):
    • Each ExistingArrayDimFetch link is converted into the equivalent ArrayDimFetch (new toArrayDimFetch()). If the scope tracks it, its container type is read from the scope (getStateType()) instead of getOffsetValueType() on the parent.
    • After the root is rewritten, the tracked links are assigned their new types again.
    • The new rebuildExistingOffsetChain() replaces the two duplicated loops (PHPDoc and native flavour).
    • When the container only maybe has the offset, the written type is unioned with the "offset absent" branch: unsetOffset() for constant arrays, the container itself otherwise, which keeps list-ness. unset() never creates a missing key, but setExistingOffsetValueType() turned array{a?: array{b: int, c: int}} into array{a: array{c: int}} after unset($x['a']['b']).
  • turbo-ext/src/AssignHandler.cpp: same change in applyWriteExistingArrayDimFetch() / rebuildExistingOffsetChain(), plus a native toArrayDimFetch().
  • Analogous cases probed: property chains ($this->prop[$key]['x']), static property chains (self::$prop[$key]['x']), deeper chains ($a[$k][$k2]['x']) and unsetting a non-constant inner key (unset($a[$k][$k2])). They all share this code path and were all broken before; they are covered by the new test.

Root cause

unset($a[$k]['x']) is modelled as the virtual write $a[$k] = <$a[$k] without 'x'>. That write rebuilt the chain only from the root variable's type, using getOffsetValueType() and setExistingOffsetValueType(). It ignored the type the scope tracks for $a[$k]. For a general array root, that type is mixed. On top of that, rewriting the root invalidated the tracked $a[$k]. The rebuild also assumed every intermediate offset exists, so optional keys of constant arrays became required.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15461.php: the issue's two functions, nested, property and static property chains, a non-constant inner key, an untracked chain (unchanged behaviour), and the two optional-key cases (array{a?: ...} root, and an optional key under a tracked link).
  • tests/PHPStan/Rules/Arrays/data/bug-15461.php + NonexistentOffsetInArrayDimFetchRuleTest::testBug15461: the playground sample verbatim, with reportPossiblyNonexistentGeneralArrayOffset enabled, expecting no errors.
  • tests/PHPStan/Rules/Methods/data/bug-12927.php: the expectation for $list[$k] after unset($list[$k]['abc']) is tightened to the now-precise array{}.

All new assertions fail without the fix. The full test suite passes with and without the turbo extension loaded, make phpstan is clean, and analysis output is identical with and without the extension.

Fixes phpstan/phpstan#15461

🤖 Generated with Claude Code

…sted offset, and keep maybe-missing offsets optional

- AssignHandler (KIND_EXISTING_ARRAY_DIM_FETCH branch, used by unset($a[...][...])):
  when the scope tracks a link of the chain (e.g. `$a[$k]` narrowed by
  is_array()/isset() with a non-constant `$k`), read its container type from
  the scope instead of `getOffsetValueType()` on the parent, and re-assign the
  link's new type after the root variable is rewritten. Before, the root's
  general array type answered `mixed` and the narrowing was lost.
- When the container only maybe has the offset, union the written type with
  the "offset absent" branch (`unsetOffset()` for constant arrays, the
  container itself otherwise) - unset() never creates a missing key, but
  `setExistingOffsetValueType()` made optional keys required
  (`array{a?: ...}` became `array{a: ...}`). This was a pre-existing bug that
  the tracked links also surfaced in IgnoredErrorHelperResult.
- Ported both changes to the turbo-ext C++ mirror (turbo-ext/src/AssignHandler.cpp).
- Probed property (`$this->p[$k]['x']`), static property, and deeper nested
  chains; they go through the same code path and are covered by the test.
- bug-12927: `$list[$k]` after `unset($list[$k]['abc'])` is now `array{}`
  (was the looser `array{}|array{abc: string}`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Array offset type is "mixed" after unset of child value.

1 participant