Mark throw points coming from a throw expression and keep them as an effect of an expression statement - #6471
Merged
ondrejmirtes merged 1 commit intoSep 18, 2026
Conversation
…n effect of an expression statement * `InternalThrowPoint`, `ThrowPoint` and `SimpleThrowPoint` carry a new `fromThrowExpr` flag telling apart a `throw` written in the analysed code from a throw inferred from what a call or an operation can throw. * `ThrowHandler` sets the flag, `subtractCatchType()` and the `InternalThrowPoint` <-> `ThrowPoint` <-> `SimpleThrowPoint` conversions preserve it. * `ExpressionHandler` no longer drops an explicit `Error` throw point when it comes from a written `throw`, so such a statement no longer emits `NoopExpressionNode`. * The flag survives the throw points of immediately invoked closures and arrow functions (`NodeScopeResolver::processArgs()`) and of closure types (`ClosureTypeResolver`, `FuncCallHandler`), so IIFEs, `$fn()` and callbacks passed to `array_map()` and friends are covered too. * Statements whose `Error` throw is only inferred (`$a / $b`, `$a % $b`, `match`, `sprintf()`, `intdiv()`, a pure function with `@throws \TypeError`) keep being reported as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hxda92rChPGzpW6YxjyZdQ
ondrejmirtes
force-pushed
the
create-pull-request/patch-wt88hws
branch
from
September 18, 2026 17:16
5a75a38 to
295fc9b
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
throw new \ValueError('...')on a separate line started being reported asExpression "throw new \ValueError('...')" on a separate line does not do anything.after c7301f6 (Do not treat possible Error throws as an effect of an expression statement).That commit filtered out every explicit throw point whose type is entirely a subtype of
Error, so that pure stdlib calls documented with@throws \ValueError(sprintf(),strpos(),intdiv(),$a / $b, …) are reported as having no effect again. The filter was too broad: it also swallowed the throw point of athrowwritten by the user, which is the whole point of such a statement.The fix keeps the filter for inferred throws, but marks throw points that come from a
throwexpression and always counts those as an effect of the statement.Changes
src/Analyser/InternalThrowPoint.php,src/Analyser/ThrowPoint.php,src/Reflection/Callables/SimpleThrowPoint.php— newfromThrowExprflag with anisFromThrowExpr()getter (optional constructor/factory argument, BC-safe), preserved bysubtractCatchType()and by the conversions between the three classes.src/Analyser/ExprHandler/ThrowHandler.php— the throw point created for athrowexpression sets the flag.src/Analyser/StmtHandler/ExpressionHandler.php— an explicitErrorthrow point is only ignored when it does not come from a writtenthrow.src/Analyser/ArgumentsHandler.php— the flag survives re-wrapping the throw points of immediately invoked closures, arrow functions and callables passed as arguments.src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php,src/Analyser/ExprHandler/FuncCallHandler.php— the flag survives the round trip throughClosureType'sSimpleThrowPoints, which is how an IIFE or a$fn()call gets its throw points.Analogous cases probed and fixed with the same flag (all were reported before, all have a regression test now):
throw new \ValueError()andthrow $ewhere$eis an\Error$n ?? throw new \ValueError()$c ? throw new \ValueError() : nulland$n ?: throw new \ValueError()$c && throw new \ValueError(),$c || throw new \ValueError()matcharm that throws an\Error(function (): void { throw new \ValueError(); })()\Errornew class { public function __construct() { throw new \ValueError(); } }\Error, passed to an immediately-invoking function such asarray_map()Analogous cases probed and found already correct (no change needed):
$x |> (static fn ($v) => throw new \ValueError())— the pipe statement was never reported.and/or/xorwith athrowright operand.@param-immediately-invoked-callable: its throw points are not part of the call's throw points at all, with\Exceptionjust as with\Error, so this is unrelated pre-existing behaviour.Cases that intentionally keep the old behaviour, because the
Errorthrow is only inferred from a signature or from an operation:$a / $b,$a % $b,match ($a) {…},sprintf(),strpos(),intdiv(),array_combine(),new \DateTimeImmutable()and a pure user function with@throws \TypeError.Root cause
ExpressionHandlerdecides whether to emitNoopExpressionNode(the node all the "has no effect" / "does not do anything" rules listen to) from the statement's impure points and explicit throw points. c7301f6 added anError-is-a-supertype test to that filter, but a throw point does not record why it exists, so the filter could not tellintdiv($a, $b)(anErrordocumented on a pure function) fromthrow new \ValueError()(anErrorthe statement exists to throw). Recording the origin on the throw point and consulting it in the filter fixes the whole family at once — every construct that can host athrowexpression (??,?:, ternary,&&,||,matcharms, arguments, closure bodies) propagates its throw points through the same plumbing, so they are all fixed by preserving the flag along those paths.Test
tests/PHPStan/Rules/DeadCode/data/bug-15258.php+NoopRuleTest::testBug15258()— the reproducer from the issue's playground link, expecting no errors.tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php+NoopRuleTest::testErrorThrows()— extended with all the analogous constructs listed above; the file keeps asserting that$a / $b,$a % $bandmatchare still reported.tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-throw-expr.php+CallToFunctionStatementWithoutSideEffectsRuleTest::testThrowExprInCallbackArgument()—array_map()with a throwing arrow function, closure and closure variable is not reported, whilearray_map()with a non-throwing callback still is.Every test above fails without the source change and passes with it.
make testsis green;make phpstanreports only a pre-existing error insrc/Analyser/ExprHandler/AssignHandler.phppresent on2.3.xbefore this change.Fixes phpstan/phpstan#15258