Add implicit throw point for dynamic instantiation of non-final classes without constructors#5683
Conversation
| 65, | ||
| ], | ||
| [ | ||
| 'Dead catch - Throwable is never thrown in the try block.', |
There was a problem hiding this comment.
rather than removing the error make the class final
There was a problem hiding this comment.
Pushed. Here's what was done to address the review:
-
bug-4806.php: Made
HasNoConstructorclassfinaland restored the dead catch assertion at line 119 (instead of removing the assertion as before). A final class with no constructor correctly has no throw points. -
bug-6574.php: Added two new test types —
ThrowsVoidInterface(interface with@throws voidconstructor) andAbstractThrowsVoid(abstract class with@throws voidconstructor) — plus test functions that verify dead catch is correctly reported for both. When a constructor explicitly declares@throws void, even non-final classes should report dead catch since the contract guarantees no exceptions.
All 12,079 tests pass and make phpstan reports no errors.
| ); | ||
| } | ||
|
|
||
| if ($classReflection !== null && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) { |
There was a problem hiding this comment.
Shouldn't the logic be placed
- in processConstructorReflection for the impure point
- in the next if for throwPoints
There was a problem hiding this comment.
Done. The review has been addressed:
-
Impure point moved into
processConstructorReflection— added abool $isDynamic = falseparameter and anelseif ($isDynamic && !$classReflection->isFinal())branch in the existing if/elseif chain. The flag is needed becauseprocessConstructorReflectionis also called from the Name branch (literalnew Foo()), where a non-final class without a constructor is genuinely pure and can't throw. -
Throw point moved into the shared if/elseif chain at lines 204-209, guarded by
!$expr->class instanceof Name && !$expr->class instanceof Node\Stmt\Class_to ensure it only fires for dynamic instantiation. -
Standalone block removed from the else branch.
All 12,079 tests pass and make phpstan reports no errors.
| * @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} | ||
| */ | ||
| private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope): array | ||
| private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic = false): array |
There was a problem hiding this comment.
remove the default value of the $isDynamic parameter
There was a problem hiding this comment.
Removed the default value from the $isDynamic parameter on processConstructorReflection and passed false explicitly at the Name branch call site (line 96). All 12,079 tests pass and make phpstan is clean.
…es without constructors - In `NewHandler::processExpr`, when a dynamic class name (`new $class()`) resolves to a single non-final class without a constructor, add an implicit throw point and an uncertain impure point. This is because the actual runtime class could be a subclass with a constructor that throws. - The fix is guarded by `implicitThrows` to remain consistent with how constructors are handled when that setting is disabled. - Fixes the false positive "Dead catch" for `class-string<Interface>`, `class-string<AbstractClass>`, and `class-string<NonFinalClass>` where the referenced type has no constructor. - Also fixes the false positive "Function returns void but does not have any side effects" for the same scenarios. - Updated `bug-4806` test which was asserting the buggy behavior for a non-final class without a constructor.
…ct classes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d test cases - Make HasNoConstructor class final in bug-4806.php so the dead catch assertion is preserved (instead of removing it) - Add test cases for interface and abstract class with @throws void constructors, which should correctly report dead catch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the impure point for dynamic instantiation of non-final classes without constructors into processConstructorReflection (via $isDynamic parameter), and the throw point into the shared if/elseif chain. This keeps related logic together rather than in a standalone block. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…uard Address review feedback: - Introduce $isDynamic local variable instead of instanceof checks - Merge the two elseif branches that both add implicit throw points - Remove $this->implicitThrows from the condition for consistency with the $classReflection === null branch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3859243 to
348389c
Compare
Summary
When using
new $class()where$classis typed asclass-string<Interface>,class-string<AbstractClass>, orclass-string<NonFinalClass>and the referenced type has no constructor, PHPStan incorrectly reported:This happened because the dynamic instantiation analysis recursively processed
new TypeName()which found no constructor, and therefore generated no throw points or impure points. At runtime, the actual class could be any concrete implementation with a constructor that throws.Changes
src/Analyser/ExprHandler/NewHandler.phpfor the dynamic class name branch when:implicitThrowsis enabled (consistent with constructor throw point handling)tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php:testBug6574test methodtests/PHPStan/Rules/Exceptions/data/bug-6574.phpwith test cases for:tests/PHPStan/Rules/Pure/data/bug-6574.phpand test method inPureFunctionRuleTest.phpto verify the "no side effects" false positive is also fixedRoot cause
In
NewHandler::processExpr, the dynamic class name branch (elsecase) resolvesnew $class()to a single class name and recursively processesnew ClassName(). The recursive call goes throughprocessConstructorReflectionwhich returns no throw/impure points when the class has no constructor. The subsequent throw point logic at lines 199-206 also doesn't fire because$classReflectionis not null (the class IS found in reflection) but$constructorReflectionIS null (no constructor). This leaves the expression with zero throw points and zero impure points.The fix adds the missing throw/impure points specifically in the dynamic branch, recognizing that for non-final classes, the actual runtime class could have a constructor. Final classes without constructors correctly remain side-effect-free since they cannot be subclassed.
Test
CatchWithUnthrownExceptionRuleTest::testBug6574- regression test covering interface, abstract class, non-final class (all without constructor, all should NOT report dead catch), and final class (should correctly report dead catch)PureFunctionRuleTest::testBug6574- regression test verifying that functions usingnew $class()with interface/abstract class-string types are not falsely flagged as having no side effectsFixes phpstan/phpstan#6574