Skip to content

Read remembered equivalent and complementary comparison forms when resolving ===, !==, ==, !=, <, <=, >, >= - #6948

Merged
VincentLanglet merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-xilbvh2
Nov 4, 2026
Merged

VincentLanglet merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-xilbvh2

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A match (true) whose arms are complementary comparisons, e.g. $bar !== $baz and then $bar === $baz, was reported as match.unhandled ("does not handle remaining value: true"). On the current branch this happened in both arm orders. After a falsey arm, the scope remembers that arm's comparison expression as false. Nothing connected that remembered value to the next arm's comparison: it is a different AST node (the complement, or the same comparison with its operands swapped). That comparison was priced from its int operands only, so it came out as bool.

The comparison type resolution now reads the equivalent and complementary forms the scope already tracks. The narrowing of a comparison also updates those forms.

Changes

  • src/Analyser/ExprHandler/BinaryOpHandler.php
    • getEquivalentComparisonForms() lists the forms that always evaluate to the same value as the comparison, or always to the opposite value:
      • ===/!== and ==/!=: both operand orders, plus the complement.
      • <, <=, >, >=: the operand-swapped mirror ($a < $b ≡ $b > $a). The complement ($a >= $b) is included only when both operands are integers. NAN and uncomparable values are neither < nor >=, so the two are not complements in general.
    • resolveRememberedComparisonType(): the type callback returns true/false when one of these forms is tracked in the scope with a constant boolean value.
    • specifyRememberedComparisonForms(): the specify callback wraps the existing narrowing. When the comparison is narrowed to true or false, it also pins the forms the scope already tracks, so a form remembered as plain bool (from an earlier merged match) does not keep answering with a stale value.
  • turbo-ext/src/BinaryOpHandler.cpp + turbo-ext/src/generated/BinaryOpHandler.h: native port of the three methods and of the wrapped specify callback.

Probed and already correct: arm pairs built from instanceof, is_*() and !$bool. Their negations read the narrowed operand, not a separate remembered expression.

Root cause

The scope keeps comparison verdicts keyed by the printed expression, so $a !== $b, $a === $b and $b === $a are unrelated keys. The BinaryOpHandler type callback priced each of them from the operand types alone and never consulted a remembered verdict stored under an equivalent or complementary key. The same pattern affected every equality and relational operator. All of them are now handled in the same place.

Test

  • tests/PHPStan/Rules/Comparison/data/bug-10583.php (MatchExpressionRuleTest::testBug10583) covers:
    • the issue's two arm orders;
    • swapped operands, ==/!= with swapped operands, </<= and >/<= on int, and match (false);
    • a float </>= pair, which is still correctly reported as unhandled.
  • tests/PHPStan/Analyser/nsrt/bug-10583.php asserts the inferred types of every form after a falsey arm. It includes the stale-bool scenario (two matches in a row) and checks that reassigning an operand invalidates the remembered verdict.
  • Both tests fail without the change.
  • The full suite passes with and without the turbo extension loaded.
  • The turbo smoke test and signature parity pass.
  • walk-trace.php is identical over its default corpus.

Fixes phpstan/phpstan#10583
Fixes phpstan/phpstan#5610

🤖 Generated with Claude Code

…solving `===`, `!==`, `==`, `!=`, `<`, `<=`, `>`, `>=`

- BinaryOpHandler type callback: before computing a comparison's type from its
  operands, look up the forms the scope may already track for it - the
  operand-swapped form and the complement (`$a === $b` <-> `$b === $a`,
  `$a !== $b`, `$b !== $a`; same for `==`/`!=`; `$a < $b` <-> `$b > $a`,
  and `$a >= $b` / `$b <= $a` as the complement only when both operands are
  integers, because NAN and uncomparable values are neither `<` nor `>=`).
  A falsey `match (true)` arm stores `$a !== $b` as `false`; a later
  `$a === $b` arm now resolves to `true`, so the match is exhaustive in
  either arm order.
- BinaryOpHandler specify callback: narrowing a comparison to true/false also
  pins the equivalent/complementary forms the scope already tracks, so a form
  remembered as plain `bool` (e.g. after an earlier merged match) cannot keep
  answering stale.
- Ported both to the native mirror in turbo-ext/src/BinaryOpHandler.cpp
  (regenerated generated/BinaryOpHandler.h for the new private methods).
- Probed but left as-is: `instanceof` / `is_*()` / `!$bool` arm pairs already
  resolve (their negation reads the narrowed operand); float `<` / `>=` stay
  non-complementary on purpose.
@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 13:02
@VincentLanglet

Copy link
Copy Markdown
Contributor

@staabm I recommend to remove the whitespace changes to review this PR.

@VincentLanglet VincentLanglet self-assigned this Nov 4, 2026

@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.

lgtm


all the testing is concentrated on match(), but shouldn't this also work for regular if ? maybe a followup

https://phpstan.org/r/c448fd56-a92d-432b-bbc1-61cfe1ec9a94

@VincentLanglet
VincentLanglet merged commit 4dab534 into phpstan:2.3.x Nov 4, 2026
1040 of 1045 checks passed
@VincentLanglet
VincentLanglet deleted the create-pull-request/patch-xilbvh2 branch November 4, 2026 14:48
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.

match.unhandled: Match arms depend on order Match integer comparision

3 participants