Skip to content

Treat match (true) as exhaustive when the arms' falsey narrowing leaves an expression typed never - #6956

Merged
staabm merged 4 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-pcc8ibq
Nov 5, 2026
Merged

staabm merged 4 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-pcc8ibq

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

match (true) arms that call a function or method with @phpstan-assert-if-true narrowed the subject expression correctly. Even so, PHPStan reported Match expression does not handle remaining value: true when every case was covered. Built-in is_float() & co. worked only because their stubs use a conditional return type. That types the last arm's condition as constant true. A user-defined assert-if-true function keeps its declared bool return type, so the last arm never became "always true".

With this change, a match on a constant boolean is also exhaustive when, after all arms fail, an expression narrowed by the arms has no possible value left (it is never).

Changes

  • src/Analyser/ExprHandler/MatchHandler.php: new private hasExpressionNarrowedToNever(). It walks the tracked expression types of the "no arm matched" scope and looks for an expression that is certainly never and was not never before the arms. It is checked next to the existing isScopeConditionallyImpossible() for constant-boolean subjects.
  • turbo-ext/src/MatchHandler.cpp: native mirror of the same check, with the same short-circuit order.

Analogous cases, all fixed by the same check and covered in the test:

  • @phpstan-assert-if-true on instance methods, static methods and functions
  • mixed is_*() / assert-method arms (only the last-arm-is-assert case was broken before)
  • property fetch subject ($this->prop) and array offset subject ($a['x'])
  • multi-condition arms
  • match (false)

Probed and already correct, kept as negative or guard cases:

  • instanceof on an A|B union was already exhaustive.
  • object with instanceof A / instanceof B arms is still correctly reported.
  • An int|float|string subject with only int and float arms is still correctly reported.

Root cause

The falsey narrowing of each arm was applied correctly: after isInt($v) and isFloat($v) both fail, $v is never. But match (true) exhaustiveness looked only at the subject itself (true) and at whether some arm condition was typed constant true. A contradiction in the narrowed scope was only detected for boolean variables with conditional expressions (isScopeConditionallyImpossible()). An ordinary expression narrowed to never was not.

Test

tests/PHPStan/Rules/Comparison/data/bug-11374.php with MatchExpressionRuleTest::testBug11374():

  • the issue's reproducer verbatim
  • the analogous cases listed above

Without the fix the test reports 7 false match.unhandled errors; with the fix only the two genuinely non-exhaustive matches are reported. The full suite and make phpstan pass, and the suite also passes with the turbo extension loaded. The turbo smoke test, method-parity check and walk-trace differential over the test data are identical.

Fixes phpstan/phpstan#11374

🤖 Generated with Claude Code

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

Compare the strategy with the one from #6947

Complexify / Performance / Other case handled

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I compared this PR with #6947 by running #6947's test files against this branch. The two take different approaches.

Strategy

  • Resolve boolean calls with @phpstan-assert-if-true/-if-false to true/false when the argument types already decide the assertion #6947 changes the call's return type. A bool call with @phpstan-assert-if-true/-if-false becomes constant true/false when the arguments already decide the assertion. It reuses ImpossibleCheckTypeHelper::findSpecifiedType() from inside the type callbacks of FuncCallHandler, MethodCallHandler and StaticCallHandler.
  • This PR changes only match exhaustiveness. For a match on a constant bool with no default arm and no always-true arm, it checks whether the "no arm matched" scope has an expression that is certainly never now but wasn't before the arms.

Complexity

Performance

Cases each one handles

Case #6947 This PR
match (true) with asserted methods, static methods, functions yes yes
match (false) with -if-false asserts yes yes
Assert targeting $this->prop no (left as bool on purpose) yes
Any narrowing to never, even when the last arm's condition stays bool no yes
Property-fetch and array-offset subjects, multi-condition arms yes yes
if/elseif chains over asserted calls (missing-return error) yes no
assertType() shows true/false for a decided asserted call yes no (stays bool)

So this PR is smaller and cheaper, and covers more match shapes. #6947 fixes the root cause more broadly: any code that reads the call's return type benefits, including missing-return detection on if/elseif. I kept this PR scoped to match, which is what the issue reports. The if/elseif gap could be a separate follow-up.

Change pushed: the PR description said match (false) was covered, but the test didn't include it. I added it in c2518b8c8, together with:

  • an exhaustive match (false) using -if-false asserts,
  • an exhaustive match (true) using asserts on $this->prop,
  • a match (false) over float|int|string that is still correctly reported as unhandled.

Without the fix, the exhaustive match (false) and the $this->prop case both report false match.unhandled errors; with it they pass.

Verification: make tests passes (23,418 tests, 107 skipped). make phpstan crashed here on its hard-coded 450M memory limit. My commit only adds test files, so it isn't the cause. With a 2G limit the same analysis reports no errors. I did not rebuild or re-run the turbo extension, because no source or C++ changed.

@VincentLanglet

Copy link
Copy Markdown
Contributor

This seems to be a simpler solution than #6947 @staabm

@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 21:52
@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.

php implementation looks good. it seems to miss someting in the CPP version though, see https://github.com/phpstan/phpstan-src/actions/runs/41916761866/job/126878382368?pr=6956

✗ turbo-ext/src/generated/MatchHandler.h is stale — run php turbo-ext/bin/generate-declarations.php

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I fixed the stale-header CI failure by regenerating turbo-ext/src/generated/MatchHandler.h and pushed it as commit afc68ab6a.

What was wrong: the new private hasExpressionNarrowedToNever() in MatchHandler.php changed the class's method signatures, but the generated declarations header still had the old ones. The hand-written MatchHandler.cpp is fine as it is. Private methods aren't registered there (isScopeConditionallyImpossible() isn't either), so the generated header was the only file out of date.

What the commit contains: only MatchHandler.h. Running php turbo-ext/bin/generate-declarations.php locally also rewrote 57 headers unrelated to this PR (for example, self in place of fully-qualified class names). That probably comes from my local PHP version (8.4.27) differing from CI's, so I left those changes out.

Checks:

  • make build-turbo compiles with the new header.
  • MatchExpressionRuleTest passes with the extension loaded (50 tests).
  • make tests passes: 23,418 tests, 107 skipped. This run was without the extension; I did not re-run the full suite with it loaded.
  • make phpstan reports no errors, but it only just fits: peak memory was 448.5 MB against its 450 MB limit. Last run it crashed on that limit, so it may still fail intermittently on memory.

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

windows builds are failling

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Still waiting on the full test suite and static analysis.

I fixed the Windows build failure and pushed it with one other fix as two commits. The Windows failure wasn't caused by this PR: the 2.3.x base branch fails the same way.

The Windows turbo build (2f069932c): MSVC stopped on NumberSet.cpp, a file this PR doesn't touch:

NumberSet.cpp(813): error C2661: 'reg::Class::method': no overloaded function takes 2 arguments

That code passes the template function pt_ns_operator<Kind> straight into the overloaded cls.method(), and MSVC can't work out which overload to use. The six operator handlers now go through plain lambdas that forward the call, the same way the other handlers in that file are registered. It compiles with make build-turbo, but I can only build on Linux, so this run of Windows CI is the first real test of the fix.

The mutation testing failure (c01146d93): this one was caused by the PR. Infection showed that replacing !$holder->getCertainty()->yes() with ->no() in hasExpressionNarrowedToNever() changes no test result. Type narrowing always marks the narrowed expression as certain. I tried several shapes (possibly-undefined variables, isset() guards, assignments inside conditions) and none let an uncertain never reach this check. So I removed the certainty check from the PHP method and from its C++ mirror in MatchHandler.cpp. The existing scopeHasNeverVariable() also ignores certainty. The generated header didn't change.

Checks:

  • make tests passes (23,418 tests, 107 skipped).
  • With the rebuilt extension loaded, smoke.php, side-by-side.php and signature-parity.php pass, and so does MatchExpressionRuleTest (50 tests). I didn't run the full suite with the extension loaded.
  • I couldn't run infection locally, so whether the mutant is now gone shows only in the next CI run.

Still failing, also failing on 2.3.x:

  • make phpstan (Windows): it runs out of its 450M memory limit, and it does the same here. With a 2G limit it reports no errors.
  • Benchmark: the 2.3.x base fails the same way.

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

move the windows build failure test in a separate pull request with its own description to keep it separate from this PR

@phpstan-bot
phpstan-bot force-pushed the create-pull-request/patch-pcc8ibq branch from c01146d to b5c8771 Compare November 5, 2026 09:23
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I moved the Windows build fix out of this PR, but I couldn't open the separate PR. The token I have isn't allowed to create pull requests on phpstan/phpstan-src (Resource not accessible by personal access token). Someone with access needs to open it.

This PR (#6956):

  • I removed the NumberSet.cpp commit (2f069932c) from the branch by rebasing, and force-pushed. The branch now ends at b5c877166.
  • The diff against 2.3.x now only touches the five files for the match fix: MatchHandler.php, the test class, the test data, MatchHandler.cpp and MatchHandler.h.
  • I dropped the commit instead of reverting it because a revert would cancel out the fix once the separate PR merges.
  • Side effect: the Windows turbo build will fail on Treat match (true) as exhaustive when the arms' falsey narrowing leaves an expression typed never #6956 again, the same way it fails on 2.3.x, until the separate fix is merged.

The separate fix:

  • The commit is now on its own branch, phpstan-bot:fix-numberset-msvc, starting from the current tip of 2.3.x.
  • It can be opened at 2.3.x...phpstan-bot:phpstan-src:fix-numberset-msvc.
  • Suggested title: "Register NumberSet operator handlers through lambdas".
  • Suggested description: MSVC fails on NumberSet.cpp(813) with error C2661, because it can't pick an overload of reg::Class::method() when it is passed the template function pt_ns_operator<Kind> directly. The six operator handlers now go through plain lambdas that forward the call, like the other handlers in that file, and behaviour is unchanged. It compiles on Linux, but the PR's Windows CI run will be the first real test of the MSVC fix.

Checks on this branch:

  • make tests passes: 23,418 tests, 107 skipped.
  • make phpstan reports no errors, though it only just fits: peak memory was 449 MB against its 450 MB limit.

@clxmstaab
clxmstaab force-pushed the create-pull-request/patch-pcc8ibq branch from 33220bd to 8705e21 Compare November 5, 2026 09:47

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

I think this looks good. lets wait a bit with merging. we need a green 2.3.x before

@VincentLanglet

Copy link
Copy Markdown
Contributor

lets wait a bit with merging. we need a green 2.3.x before

We risk conflict on cpp... And the bot is not fixing them. I had to close/regenerate the PR everytime.

VincentLanglet and others added 4 commits November 5, 2026 11:53
…aves an expression typed `never`

- MatchHandler: for a constant-boolean subject (`match (true)` / `match (false)`)
  with no default arm and no always-true condition, check the scope left after
  every arm's falsey narrowing. If an expression that still had a possible value
  before the arms is now certainly `never` (a variable, a property fetch, ...),
  no arm failing to match is impossible, so the match is exhaustive. Before this,
  exhaustiveness relied on the last arm's condition being typed constant `true`.
  That works for `is_float()` (its stub has a conditional return type) but not
  for functions/methods that only declare `@phpstan-assert-if-true`.
- Mirrored the new hasExpressionNarrowedToNever() in turbo-ext/src/MatchHandler.cpp.
- Analogous cases covered by the same fix and tested: instance methods,
  static methods and functions with `@phpstan-assert-if-true`, mixing them with
  `is_*()`, property fetch subjects, array offset subjects, multi-condition arms
  and `match (false)`. Non-exhaustive cases (`object` with `instanceof`
  arms, a union with a member no arm covers) are still reported.
…p` in bug-11374 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oNever()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Narrowing always writes a certain (Yes) holder, so a maybe-certain
never-typed expression cannot reach this check - the condition was
dead and left an escaped mutant. scopeHasNeverVariable() also ignores
certainty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-pcc8ibq branch from 8705e21 to cdf764a Compare November 5, 2026 10:53
@staabm
staabm merged commit 67fb1de into phpstan:2.3.x Nov 5, 2026
580 of 581 checks passed
@staabm
staabm deleted the create-pull-request/patch-pcc8ibq branch November 5, 2026 11:06
@staabm

staabm commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Thank you!

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