Skip to content

Use isFromThrowExpr() instead of the throw point's node shape to decide whether implicit throw points still apply - #6484

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-xfymi30
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-xfymi30

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

The issue reported that an inferred-throwing call (Db::getThing(), no @throws tag) stopped being treated as a throw point as soon as a later call in the same try block had a declared @throws, so the variable assigned from it was typed without its pre-assignment value in the catch block and $thing === null was reported as identical.alwaysFalse.

The reported scenario is no longer reproducible - it was fixed by 7d2352b ("Include implicit throw points when analysing broad catches"), which makes catch (\Exception) / catch (\Error) / catch (\Throwable) keep the implicit throw points. This PR adds the missing regression tests for that scenario and fixes an analogous case of the same mechanism that was still broken: a literal throw relayed through a closure was mistaken for documented @throws, which dropped every implicit throw point of the try block.

Changes

  • src/Analyser/StmtHandler/TryCatchHandler.php: the "only explicit throw point is a written throw" check no longer inspects the throw point's AST node; it asks InternalThrowPoint::isFromThrowExpr(), the flag built for exactly this question.
  • src/Analyser/StatementsHandler.php: getOverridingThrowPoints() passes fromThrowExpr: true when the statement carrying the /** @throws */ annotation is a throw statement, so that corner keeps behaving as before the change.
  • tests/PHPStan/Analyser/nsrt/bug-14990.php (new): the playground reproducer from the issue.
  • tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php: asserts the reproducer no longer reports identical.alwaysFalse.
  • tests/PHPStan/Analyser/nsrt/explicit-throws.php: five new failing-before-the-fix cases (throw in an arrow function argument, in a closure argument, in a called closure variable, in an immediately invoked closure, in a closure variable passed as a callable), plus two cases locking in the intended behaviour (a statement-level @throws above a throw, and a closure argument that calls a documented thrower - documentation still wins there).

Analogous cases probed and found already correct (no test kept): documented constructors (new) with a broad catch, union catch types (catch (\Exception | \Error)), a broad second catch clause after a narrow one, finally scopes, nested try/catch rethrows of a literal throw, intdiv()/% operation throw points with catch (\Error), and closures passed to parameters that are not immediately invoked (their throw points are not propagated at all).

Root cause

TryCatchHandler merges the scopes of all throw points that match a catch type to build the catch scope. Implicit (inferred, undocumented) throw points are dropped when a matching explicit one exists, unless the catch is broad or the only matching explicit throw points are throws written in the analysed code ($onlyExplicitIsThrow).

That last condition was derived from the throw point's AST node: Expr\Throw_, or a Stmt\Expression wrapping one. But a throw point does not keep its original node when it is relayed out of a callable: ArgumentsHandler re-creates it with the argument expression as node, and FuncCallHandler with the call expression. Those relays do preserve isFromThrowExpr(), which documents precisely "comes from a throw written in the analysed code, as opposed to a throw inferred from what a called function or an operation can throw" - the node shape was a lossy re-derivation of that flag. As a result array_map(static fn () => throw new \InvalidArgumentException(), $a) counted as documentation, and every inferred throw point in the try block was dropped from the catch scope, making variables look more certain and more narrowly typed than they are.

Test

tests/PHPStan/Analyser/nsrt/bug-14990.php reproduces the issue's playground sample and asserts Thing|null plus Maybe certainty in the catch; it is also analysed by StrictComparisonOfDifferentTypesRuleTest::testBug14990(), which asserts that no identical.alwaysFalse error is reported.

tests/PHPStan/Analyser/nsrt/explicit-throws.php gains five cases that fail without the fix (certainty of $a reported as Yes instead of Maybe) and two cases that guard against over-reaching, where a documented @throws must keep winning over the inferred throw points.

Fixes phpstan/phpstan#14990

@staabm
staabm deleted the create-pull-request/patch-xfymi30 branch September 20, 2026 15:09
@ondrejmirtes
ondrejmirtes restored the create-pull-request/patch-xfymi30 branch September 20, 2026 15:30
@ondrejmirtes ondrejmirtes reopened this Sep 20, 2026
@ondrejmirtes

Copy link
Copy Markdown
Member

Guys, I triggered this myself 😊 The goal was to find the fixing commit which this PR allegedly did + fixed more and added regression tests.

…cide whether implicit throw points still apply

* `TryCatchHandler` decided whether the only matching explicit throw points are `throw`s written in the analysed code by inspecting the throw point's AST node (`Expr\Throw_` / `Stmt\Expression` wrapping one). Throw points relayed through a callable value lose that node - the node becomes the argument expression or the call expression - so a literal `throw` inside a closure or arrow function was mistaken for a documented `@throws`, and all implicit (inferred) throw points of the `try` block were dropped from the catch scope.
* The purpose-built `InternalThrowPoint::isFromThrowExpr()` flag is preserved through all of those relays (`ArgumentsHandler`, `FuncCallHandler`, `ClosureTypeResolver`), so the node sniffing is replaced by asking the flag.
* `StatementsHandler::getOverridingThrowPoints()` now marks the throw point it creates for a statement-level `/** @throws */` as coming from a throw expression when the annotated statement is a `throw` statement, keeping the previous behaviour of that corner intact.
* Probed and found already correct: broad `catch (\Exception)` / `catch (\Error)` / `catch (\Throwable)` with a documented thrower (the reported scenario, fixed by 7d2352b), union catch types, second catch clauses, documented constructors, `finally` scopes, nested try/catch rethrows, and closure arguments of parameters that are not immediately invoked.
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-xfymi30 branch from a29446a to 590d44c Compare September 20, 2026 15:32
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.

Inferred-throwing call is dropped as a throw point when a later call has a declared @throws

3 participants