Skip to content

Resolve array offset access on never to never and treat never operands of ===/!== as undecided#5906

Closed
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-20i99q4
Closed

Resolve array offset access on never to never and treat never operands of ===/!== as undecided#5906
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-20i99q4

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

An impossible identical comparison inside assert() over a constant array — e.g. assert($array[1] === null) where $array[1] is 0 — collapses the whole array to never (which is correct). The bug was that the following statements then produced a cascade of spurious Strict comparison using === between *NEVER* and null will always evaluate to false. errors, and that reading a further offset of the now-never array produced an *ERROR* type instead of never.

This was the regression behind the revert of d7ba1e3 ("More precise array-item types in loops"). This change fixes the root cause so the precision improvement no longer produces the cascade.

Changes

  • src/Analyser/ExprHandler/ArrayDimFetchHandler.php — short-circuit resolveType() when the offset-accessible type is NeverType, returning never. Previously, because never is a subtype of everything (including ArrayAccess), the fetch was routed through offsetGet() and produced *ERROR*.
  • src/Reflection/InitializerExprTypeResolver.phpresolveIdenticalType() now returns a non-constant BooleanType (instead of ConstantBooleanType(false)) when either operand is never, so ===/!== no longer reports always-true/false on already-unreachable code.
  • tests/PHPStan/Analyser/data/bug-9307.php — the loop case now correctly infers array<int, Bug9307\Item> instead of array<*ERROR*> (the inline comment already predicted this).
  • tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php — the last-match-arm expectations drop the two *NEVER* === 'ccc' reports, plus a new testBug14281.
  • Added regression tests tests/PHPStan/Analyser/nsrt/bug-14281.php and tests/PHPStan/Rules/Comparison/data/bug-14281.php.

Root cause

Two independent spots mishandled never:

  1. Offset read on never. ArrayDimFetchHandler checks (new ObjectType(ArrayAccess::class))->isSuperTypeOf($type)->yes() to decide whether to call offsetGet(). never satisfies that test (it is a subtype of every type), so an offset read on a never array was treated as an ArrayAccess::offsetGet() call and yielded *ERROR*. NeverType::getOffsetValueType() already returns never; the handler just never reached it.

  2. Identical comparison with a never operand. resolveIdenticalType() returned a constant false, which the StrictComparisonOfDifferentTypesRule / ImpossibleCheckType* rules report as "always false". A never operand denotes unreachable code that carries no comparable value, so the result should be undecided — consistent with how never already behaves as a boolean condition (if, &&, ||) and with NeverType::looseCompare() for ==/!=.

Test

  • nsrt/bug-14281.php asserts that after assert($array[1] === null) the array is *NEVER*, that $array[2] is *NEVER* (not *ERROR*), and that $i === null / $i !== null on a never variable are inferred as bool. It fails without the fix (Actual: *ERROR* and constant-boolean results).
  • Rules/Comparison/data/bug-14281.php + testBug14281 confirm the rule only reports the genuinely-impossible first comparisons (null === null, 0 === null, int !== int) and no longer reports the unreachable *NEVER* === null / *NEVER* !== null follow-ups.
  • Probed analogous comparisons: ==/!= already produce a non-constant boolean via NeverType::looseCompare(); <=>/</> produce never without an always-true/false report. No change needed for those.

Fixes phpstan/phpstan#14281

@staabm
staabm force-pushed the create-pull-request/patch-20i99q4 branch from b5b4dbe to 661cf30 Compare June 21, 2026 15:46

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve conflicts and rebase

Comment on lines -689 to -692
[
"Strict comparison using === between *NEVER* and 'ccc' will always evaluate to false.",
38,
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need this errors to stay

@staabm
staabm force-pushed the create-pull-request/patch-20i99q4 branch from 661cf30 to cae1545 Compare June 27, 2026 11:53
phpstan-bot added a commit to phpstan-bot/phpstan-src that referenced this pull request Jul 18, 2026
A case whose subject was already exhausted to never sits on unreachable
code. A never operand keeps the loose comparison undecided (see phpstan#5906,
which established never operands of ===/== as undecided precisely to stop
piling always-true/false errors onto unreachable code), so the trailing
case is deliberately not reported. Clarify the misleading fixture and add
an explicit no-error note to the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…perands of `===`/`!==` as undecided

- ArrayDimFetchHandler::resolveType() now short-circuits when the
  offset-accessible type is NeverType. Because never is a subtype of
  everything (including ArrayAccess), the fetch was otherwise resolved
  through offsetGet() and produced an *ERROR* type instead of never.
- InitializerExprTypeResolver::resolveIdenticalType() returns a
  non-constant BooleanType (instead of ConstantBooleanType(false)) when
  either operand is never. A never-typed operand has no value to compare,
  so the comparison is undecided. This mirrors how never already behaves
  as a boolean condition (if/&&/||) and stops StrictComparison /
  ImpossibleCheck rules from piling always-true/false errors onto
  already-unreachable code.
- Together these let an impossible assertion such as
  `assert($array[1] === null)` collapse the array to never without
  emitting a cascade of `*NEVER* === ...` comparison errors on the
  following statements.
- Updated the last-match-arm rule test (the `*NEVER* === 'ccc'` reports
  are now suppressed) and bug-9307 (`array<*ERROR*>` is now correctly
  inferred as `array<int, Bug9307\Item>`).
- Probed siblings: `==`/`!=` already resolve to a non-constant boolean
  via NeverType::looseCompare(); `<=>`/`<`/`>` yield never without an
  always-true/false report, so no change was needed there.
@staabm
staabm force-pushed the create-pull-request/patch-20i99q4 branch from cae1545 to d8a8abe Compare July 18, 2026 12:37
Comment on lines +1950 to +1953
// A never-typed operand has no value to compare, so the result is
// undecided. This mirrors how never behaves as a boolean condition and
// keeps always-true/false rules from piling onto already-unreachable code.
return new TypeResult(new BooleanType(), []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert this part

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's a summary of how I addressed the review.

What the reviewer asked

  1. "revert this part" — the InitializerExprTypeResolver::resolveIdenticalType() change that returned a non-constant BooleanType for never operands.
  2. "we need this errors to stay" (earlier comment) — the *NEVER* === 'ccc' always-false reports removed from the match-arm test.

Changes made

  • src/Reflection/InitializerExprTypeResolver.php — reverted resolveIdenticalType() back to return new TypeResult(new ConstantBooleanType(false), []) for never operands. The ArrayDimFetchHandler fix (offset access on nevernever instead of *ERROR*) is untouched, since the review only targeted the resolver.
  • StrictComparisonOfDifferentTypesRuleTest.php — restored the two *NEVER* === 'ccc' (line 38) error expectations in dataLastMatchArm, and updated testBug14281 to expect the always-false/true reports that reappear on the unreachable comparisons.
  • tests/PHPStan/Rules/Comparison/data/bug-14281.php — corrected the now-inaccurate comments (the comparisons on the collapsed-to-never array are reported again) instead of leaving the false "must not be reported" claim.
  • tests/PHPStan/Analyser/nsrt/bug-14281.php — updated neverVariable expectations from bool to the actual false/true constant results; the array-offset assertions (the remaining real fix) are unchanged.

I empirically confirmed the reverted behavior via the failing/passing tests rather than guessing line numbers.

Verification

  • testBug14281, testLastMatchArm, bug-14281 nsrt, and bug-9307 — all pass.
  • Full tests/PHPStan/Rules/Comparison/ (426) and NodeScopeResolverTest (1679) — all green.
  • make phpstan — no errors.

Committed as a single logical change and pushed to create-pull-request/patch-20i99q4.

One thing worth flagging for the reviewer: with the resolver reverted but the ArrayDimFetchHandler fix kept, offset reads on a collapsed-to-never array now produce genuine never (correct), which the strict-comparison rule then reports as always-false on the deeply-unreachable follow-up statements. That's why testBug14281 gained the extra *NEVER* === null expectations — the reproducer now emits more reports on unreachable code than before, which is the opposite of the issue's "no errors" goal. If that cascade is undesirable, the two changes may need to move together rather than splitting them.

Revert resolveIdenticalType() to return ConstantBooleanType(false) when
either operand is never, as requested in review. The always-false/true
reports on never operands (including *NEVER* === 'ccc' in the last match
arm) are kept. The array offset access on never -> never fix in
ArrayDimFetchHandler is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm staabm closed this Jul 18, 2026
@staabm
staabm deleted the create-pull-request/patch-20i99q4 branch July 18, 2026 13:07
staabm pushed a commit to phpstan-bot/phpstan-src that referenced this pull request Jul 18, 2026
A case whose subject was already exhausted to never sits on unreachable
code. A never operand keeps the loose comparison undecided (see phpstan#5906,
which established never operands of ===/== as undecided precisely to stop
piling always-true/false errors onto unreachable code), so the trailing
case is deliberately not reported. Clarify the misleading fixture and add
an explicit no-error note to the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
staabm pushed a commit to phpstan-bot/phpstan-src that referenced this pull request Jul 19, 2026
A case whose subject was already exhausted to never sits on unreachable
code. A never operand keeps the loose comparison undecided (see phpstan#5906,
which established never operands of ===/== as undecided precisely to stop
piling always-true/false errors onto unreachable code), so the trailing
case is deliberately not reported. Clarify the misleading fixture and add
an explicit no-error note to the test.

Co-Authored-By: Claude Opus 4.8 <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.

type narrowed too much after identical comparison

2 participants