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
Conversation
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
force-pushed
the
create-pull-request/patch-xfymi30
branch
from
September 20, 2026 15:32
a29446a to
590d44c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The issue reported that an inferred-throwing call (
Db::getThing(), no@throwstag) stopped being treated as a throw point as soon as a later call in the sametryblock had a declared@throws, so the variable assigned from it was typed without its pre-assignment value in thecatchblock and$thing === nullwas reported asidentical.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 literalthrowrelayed through a closure was mistaken for documented@throws, which dropped every implicit throw point of thetryblock.Changes
src/Analyser/StmtHandler/TryCatchHandler.php: the "only explicit throw point is a writtenthrow" check no longer inspects the throw point's AST node; it asksInternalThrowPoint::isFromThrowExpr(), the flag built for exactly this question.src/Analyser/StatementsHandler.php:getOverridingThrowPoints()passesfromThrowExpr: truewhen the statement carrying the/** @throws */annotation is athrowstatement, 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 reportsidentical.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@throwsabove athrow, 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,finallyscopes, nested try/catch rethrows of a literalthrow,intdiv()/%operation throw points withcatch (\Error), and closures passed to parameters that are not immediately invoked (their throw points are not propagated at all).Root cause
TryCatchHandlermerges 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 arethrows written in the analysed code ($onlyExplicitIsThrow).That last condition was derived from the throw point's AST node:
Expr\Throw_, or aStmt\Expressionwrapping one. But a throw point does not keep its original node when it is relayed out of a callable:ArgumentsHandlerre-creates it with the argument expression as node, andFuncCallHandlerwith the call expression. Those relays do preserveisFromThrowExpr(), which documents precisely "comes from athrowwritten 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 resultarray_map(static fn () => throw new \InvalidArgumentException(), $a)counted as documentation, and every inferred throw point in thetryblock 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.phpreproduces the issue's playground sample and assertsThing|nullplusMaybecertainty in thecatch; it is also analysed byStrictComparisonOfDifferentTypesRuleTest::testBug14990(), which asserts that noidentical.alwaysFalseerror is reported.tests/PHPStan/Analyser/nsrt/explicit-throws.phpgains five cases that fail without the fix (certainty of$areported asYesinstead ofMaybe) and two cases that guard against over-reaching, where a documented@throwsmust keep winning over the inferred throw points.Fixes phpstan/phpstan#14990