Skip to content

Read untracked fetch links of an isset chain from the asking scope's state - #7065

Merged
ondrejmirtes merged 1 commit into
2.3.xfrom
isset-chain-untracked-fetch-state
Nov 14, 2026
Merged

ondrejmirtes merged 1 commit into
2.3.xfrom
isset-chain-untracked-fetch-state

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Fixes problem A of phpstan/phpstan#15722 (isset/?? narrowing leaking out of the expression for chains of three or more links).

The chain links of an isset()/empty()/?? subject are walked under NonNullabilityHelper's ensured non-nullability. Ensuring a nested offset non-null also narrows the fetches it is read from, so the stored results of those links carry null-stripped types. IssetabilityDescriptor read an untracked receiver link through its stored result. From three links on ($p['r']['a']['b'], $this->data['a']['b']), the verdict therefore saw a nullable intermediate offset as non-null:

  • isset($p['r']['a']['b']) was true, so isset(...) ? ... : null lost its null (return.unusedType).
  • $p['r']['a']['b'] ?? null was int. Its right side was unreachable, and merging the scopes dropped |null from $p after the expression (isset.offset later on).
  • The isset/?? rules reported the offset as always existing (nullCoalesce.offset, isset.offset), including for property-rooted chains, whose rule-facing re-pricing reused the stored results because $this itself never changes.

Two-link chains were not affected: there the receiver is the variable, which is always read from the scope.

An untracked array dim fetch, property fetch or static property fetch link is now read from the asking scope's state (MutatingScope::getStateType()), derived from its receiver's state without a walk. Tracked links, nullsafe chains and other link kinds (calls) keep the previous reads. The native mirror in turbo-ext/src/IssetabilityDescriptor.cpp gets the same change.

Problem B of the issue (inverted correlation after while) is the while loop's exit condition not re-applying its own assignments. That is the same root cause #7063 fixes in WhileHandler, so this PR leaves it out to avoid a conflict. With #7063, $stop is bool there, which the issue accepts.

Verified:

  • tests/PHPStan/Analyser/nsrt/bug-15722.php and the testBug15722 cases in IssetRuleTest/NullCoalesceRuleTest fail without the change.
  • Full make tests passes with and without the extension loaded.
  • Self-analysis output is byte-identical with the extension on and off.
  • Turbo checks pass: turbo-ext/tests/smoke.php, signature-parity.php, and method parity in side-by-side.php.

…state

The chain links of an isset()/empty()/?? subject are walked under
NonNullabilityHelper's ensured non-nullability, and ensuring a nested
offset non-null also narrows the fetches it is read from. Their stored
results therefore carry null-stripped types. IssetabilityDescriptor read
an untracked receiver link through that stored result, so from three
links on ($a['r']['a']['b'], $this->data['a']['b']) the verdict saw a
nullable intermediate offset as non-null: isset() and ?? were typed as
always set, the right side of ?? became unreachable and its merge
dropped null from the subject, and the isset/?? rules reported the
offset as always existing.

An untracked array dim fetch, property fetch or static property fetch
link is now read from the asking scope's state, derived from its
receiver's state without a walk.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d722-233f-76e4-a119-f3620177393b
@ondrejmirtes
ondrejmirtes merged commit 1cc0ba8 into 2.3.x Nov 14, 2026
901 of 907 checks passed
@ondrejmirtes
ondrejmirtes deleted the isset-chain-untracked-fetch-state branch November 14, 2026 09:05
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.

1 participant