Skip to content

Resolve boolean calls with @phpstan-assert-if-true/-if-false to true/false when the argument types already decide the assertion - #6947

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-evzopyk
Open

phpstan-bot wants to merge 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-evzopyk

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A match (true) whose last arm is a call with @phpstan-assert-if-true was reported as "Match expression does not handle remaining value: true", even though the earlier arms had already narrowed the subject to the asserted type. is_float() and friends work in the same position because their stub's conditional return type (($value is float ? true : false)) resolves the call to true. A user-defined asserted method stayed bool, even though PHPStan already reports it as "will always evaluate to true".

This change pins a boolean call with conditional asserts to true/false whenever the argument types already decide the assertion. It covers functions, methods, static methods and nullsafe method calls, and -if-false as well as -if-true. It also fixes the same problem with if/elseif chains, which wrongly reported "should return string but return statement is missing".

Changes

  • src/Rules/Comparison/ImpossibleCheckTypeHelper.php: new hasConditionalAsserts() and resolveConditionalAssertReturnType(). The latter reuses findSpecifiedType() (the verdict behind the ImpossibleCheckType* rules) and only touches non-constant boolean return types.
  • src/Analyser/ExprHandler/FuncCallHandler.php, MethodCallHandler.php, StaticCallHandler.php: the type callback applies that verdict after resolveReturnType() when the callee has conditional asserts. The flag is computed once per walk, so calls without asserts pay nothing extra. The call's own ExpressionResult is read through a weak storage reference, the same pattern FuncCallHandler already uses for in_array()/array_key_exists().
  • turbo-ext/src/FuncCallHandler.cpp, MethodCallHandler.cpp, StaticCallHandler.cpp, CallHandlerSupport.h, generated/{MethodCallHandler,StaticCallHandler}.h: native mirror of the above (new constructor dependency, extra type-callback captures, shared hasConditionalAsserts / weak-reference / helper-call functions in CallHandlerSupport.h).

Analogous cases probed:

  • Fixed: functions, static methods, @phpstan-assert-if-false with match (false), negated !Type asserts, nullsafe ?-> calls, and if/elseif missing-return detection.
  • Left as bool on purpose: asserts targeting $this->property, first-class callables ($f = $obj->isX(...)), unconditional @phpstan-assert and equality (=Type) asserts. The ImpossibleCheckType* rules don't decide these either, so behaviour stays consistent.

Root cause

For a call, "the check is already decided" was known in two places that didn't talk to each other. The ImpossibleCheckType* rules worked it out from the call's narrowing (findSpecifiedType()). The call's return type only reflected it when the callee declared a conditional return type, which built-in type checks do through stubs and @phpstan-assert-if-* methods don't. Every consumer of the return type was therefore blind to it: match exhaustiveness (hasAlwaysTrueCond), dead-branch and missing-return detection. Now the return type reads the same verdict.

Test

  • tests/PHPStan/Rules/Comparison/data/bug-11374.php: the playground reproducer, unchanged. No match.unhandled errors any more; the existing "always true" reports are still expected.
  • tests/PHPStan/Rules/Comparison/data/bug-11374-2.php: functions, static methods, -if-false + match (false), and a still-unhandled case (float|int|string) that must keep reporting.
  • tests/PHPStan/Rules/Missing/data/bug-11374.php: if/elseif chains over asserted methods, static methods and functions, plus one that must still report the missing return.
  • tests/PHPStan/Analyser/nsrt/bug-11374.php: assertType() for decided, undecided, -if-false, negated, static, function, unconditional-assert and equality-assert calls.

All of these fail without the fix. make tests and make phpstan pass. With the turbo extension built and loaded, the full test suite, smoke.php, signature-parity.php, side-by-side.php and the walk-trace.php differential (identical traces) all pass.

Fixes phpstan/phpstan#11374

…true`/`false` when the argument types already decide the assertion

- ImpossibleCheckTypeHelper: add hasConditionalAsserts() and
  resolveConditionalAssertReturnType(), which pins a non-constant boolean
  return type to ConstantBooleanType using the existing findSpecifiedType()
  verdict, i.e. the verdict the ImpossibleCheckType* rules already report
  as "will always evaluate to true/false"
- FuncCallHandler, MethodCallHandler, StaticCallHandler: when the callee has
  conditional asserts, the type callback applies that verdict after
  resolveReturnType(). The call's own ExpressionResult is read through a weak
  storage reference, as FuncCallHandler already does for in_array() etc.
  Nullsafe method calls are covered through MethodCallHandler.
- Built-in checks like is_float() already resolved this way through their
  conditional return types. User-defined asserted checks stayed `bool`, so a
  `match (true)` whose last arm was such a call was reported as not handling
  `true`, and an if/elseif chain over them got "return statement is missing".
- turbo-ext: mirror the change in FuncCallHandler.cpp, MethodCallHandler.cpp
  and StaticCallHandler.cpp (new constructor dependency, new type-callback
  captures). The WeakReference helpers move from FuncCallHandler.cpp into
  CallHandlerSupport.h so the three handlers share them. The generated
  declarations for the two method handlers are regenerated.
- Probed and left alone: asserts on `$this->property` and first-class
  callables `$f = $obj->isX(...)` still give `bool`, consistently with the
  ImpossibleCheckType* rules, which do not decide these either. Unconditional
  `@phpstan-assert` and equality (`=Type`) asserts are unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@staabm staabm mentioned this pull request Nov 4, 2026

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

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.

false positive: "Match expression does not handle remaining value: true"

3 participants