From 06c967ac51c5f4e63174d2e6374a6dbd21b6c9fe Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:29:49 +0000 Subject: [PATCH 1/8] Fix phpstan/phpstan#14323: Inconsistent variable definedness in new expression arguments - Constructor throw points in NewHandler were created before processArgs(), using a scope that didn't include variable assignments from constructor arguments - Refactored NewHandler to defer constructor throw point creation until after processArgs() completes, matching the pattern used by MethodCallHandler - Split processConstructorReflection() to separate throw point creation into createConstructorThrowPoints() which uses the post-args scope - Added regression test in tests/PHPStan/Rules/Variables/data/bug-14323.php --- src/Analyser/ExprHandler/NewHandler.php | 45 ++++-- .../Variables/DefinedVariableRuleTest.php | 23 ++++ .../Rules/Variables/data/bug-14323.php | 130 ++++++++++++++++++ 3 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-14323.php diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 0d48d1cfabc..e015b8aa2ae 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -86,12 +86,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; + $classReflection = null; + $classFound = true; + $deferConstructorThrowPoints = false; if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); - [$constructorReflection, $parametersAcceptor, $constructorThrowPoints, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); - $throwPoints = array_merge($throwPoints, $constructorThrowPoints); + [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); + $deferConstructorThrowPoints = true; if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; @@ -176,9 +179,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $parametersAcceptor, $constructorThrowPoints, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); - $throwPoints = array_merge($throwPoints, $constructorThrowPoints); + [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); + $deferConstructorThrowPoints = true; } else { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); $impurePoints[] = new ImpurePoint( @@ -202,6 +205,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + if ($deferConstructorThrowPoints) { + $throwPoints = array_merge($throwPoints, $this->createConstructorThrowPoints($expr, $scope, $constructorReflection, $parametersAcceptor, $classReflection, $classFound)); + } + return new ExpressionResult( $scope, hasYield: $hasYield, @@ -212,16 +219,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } /** - * @return array{?MethodReflection, ?ParametersAcceptor, InternalThrowPoint[], ImpurePoint[]} + * @return array{?MethodReflection, ?ParametersAcceptor, ?ClassReflection, bool, ImpurePoint[]} */ private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope): array { $constructorReflection = null; $parametersAcceptor = null; - $throwPoints = []; $impurePoints = []; $classReflection = null; + $classFound = true; if ($this->reflectionProvider->hasClass($className)) { $classReflection = $this->reflectionProvider->getClass($className); if ($classReflection->hasConstructor()) { @@ -232,13 +239,9 @@ private function processConstructorReflection(string $className, New_ $expr, Mut $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants(), ); - $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope); - if ($constructorThrowPoint !== null) { - $throwPoints[] = $constructorThrowPoint; - } } } else { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + $classFound = false; } if ($constructorReflection !== null) { @@ -262,7 +265,25 @@ private function processConstructorReflection(string $className, New_ $expr, Mut ); } - return [$constructorReflection, $parametersAcceptor, $throwPoints, $impurePoints]; + return [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $impurePoints]; + } + + /** + * @return InternalThrowPoint[] + */ + private function createConstructorThrowPoints(New_ $expr, MutatingScope $scope, ?MethodReflection $constructorReflection, ?ParametersAcceptor $parametersAcceptor, ?ClassReflection $classReflection, bool $classFound): array + { + $throwPoints = []; + if ($classReflection !== null && $constructorReflection !== null && $parametersAcceptor !== null) { + $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($classReflection->getName()), $expr->getArgs(), $scope); + if ($constructorThrowPoint !== null) { + $throwPoints[] = $constructorThrowPoint; + } + } elseif (!$classFound) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + } + + return $throwPoints; } /** diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index a5db7514764..df0bfac7404 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1432,6 +1432,29 @@ public function testBug14318(): void $this->analyse([__DIR__ . '/data/bug-14318.php'], []); } + public function testBug14323(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-14323.php'], [ + [ + 'Variable $command might not be defined.', + 40, + ], + [ + 'Variable $command might not be defined.', + 66, + ], + [ + 'Variable $command might not be defined.', + 118, + ], + ]); + } + #[RequiresPhp('>= 8.0')] public function testBug14274(): void { diff --git a/tests/PHPStan/Rules/Variables/data/bug-14323.php b/tests/PHPStan/Rules/Variables/data/bug-14323.php new file mode 100644 index 00000000000..f231ba06c48 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14323.php @@ -0,0 +1,130 @@ + $a */ + public function __construct(array $a) {} +} + +class Process2 { + /** + * @param array $a + * @throws ProcessFailedException + */ + public function __construct(array $a) {} +} + +class Process3 { + /** + * @param array $a + * @throws void + */ + public function __construct(array $a) {} +} + +abstract class DbCommand +{ + /** + * @return int + */ + public function handle() + { + try { + new Process( + array_merge([$command = $this->getCommand()]) + ); + } catch (ProcessFailedException $e) { + echo ("{$command} not found in path."); + + return 1; + } + + return 0; + } + + /** + * @return string + */ + abstract public function getCommand(); +} + +abstract class DbCommand2 +{ + /** + * @return int + */ + public function handle() + { + try { + new Process( + [$command = $this->getCommand()] + ); + } catch (ProcessFailedException $e) { + echo ("{$command} not found in path."); + + return 1; + } + + return 0; + } + + /** + * @return string + */ + abstract public function getCommand(); +} + +abstract class DbCommand3 +{ + /** + * @return int + */ + public function handle() + { + try { + new Process2( + array_merge([$command = $this->getCommand()]) + ); + } catch (ProcessFailedException $e) { + echo ("{$command} not found in path."); + + return 1; + } + + return 0; + } + + /** + * @return string + */ + abstract public function getCommand(); +} + +abstract class DbCommand4 +{ + /** + * @return int + */ + public function handle() + { + try { + new Process3( + array_merge([$command = $this->getCommand()]) + ); + } catch (ProcessFailedException $e) { + echo ("{$command} not found in path."); + + return 1; + } + + return 0; + } + + /** + * @return string + */ + abstract public function getCommand(); +} From 555326e08022c52f9f15ff1980659a0f42a94640 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 15:47:58 +0000 Subject: [PATCH 2/8] Inline constructor throw point creation to match MethodCallHandler pattern Remove createConstructorThrowPoints() method and call getConstructorThrowPoint() directly at the call site, matching how MethodCallHandler uses getMethodThrowPoint(). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/NewHandler.php | 27 ++++++++----------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index e015b8aa2ae..85e7b09d3b8 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -206,7 +206,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); if ($deferConstructorThrowPoints) { - $throwPoints = array_merge($throwPoints, $this->createConstructorThrowPoints($expr, $scope, $constructorReflection, $parametersAcceptor, $classReflection, $classFound)); + if ($classReflection !== null && $constructorReflection !== null && $parametersAcceptor !== null) { + $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($classReflection->getName()), $expr->getArgs(), $scope); + if ($constructorThrowPoint !== null) { + $throwPoints[] = $constructorThrowPoint; + } + } elseif (!$classFound) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + } } return new ExpressionResult( @@ -268,24 +275,6 @@ private function processConstructorReflection(string $className, New_ $expr, Mut return [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $impurePoints]; } - /** - * @return InternalThrowPoint[] - */ - private function createConstructorThrowPoints(New_ $expr, MutatingScope $scope, ?MethodReflection $constructorReflection, ?ParametersAcceptor $parametersAcceptor, ?ClassReflection $classReflection, bool $classFound): array - { - $throwPoints = []; - if ($classReflection !== null && $constructorReflection !== null && $parametersAcceptor !== null) { - $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($classReflection->getName()), $expr->getArgs(), $scope); - if ($constructorThrowPoint !== null) { - $throwPoints[] = $constructorThrowPoint; - } - } elseif (!$classFound) { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); - } - - return $throwPoints; - } - /** * @param list $args */ From ccf0a02d6bc7ddf95fa8a0772648a7a8825d44a0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 16:04:23 +0000 Subject: [PATCH 3/8] Simplify processConstructorReflection: remove classReflection and classFound from return Address review feedback: classFound is equivalent to checking reflectionProvider->hasClass() and classReflection can be obtained from the reflectionProvider at the call site, so processConstructorReflection's signature doesn't need to change. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/NewHandler.php | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 85e7b09d3b8..965c4482be1 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -86,13 +86,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; - $classReflection = null; - $classFound = true; + $className = null; $deferConstructorThrowPoints = false; if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); - [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); + [$constructorReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); $deferConstructorThrowPoints = true; @@ -179,7 +178,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); + [$constructorReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); $deferConstructorThrowPoints = true; } else { @@ -205,13 +204,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - if ($deferConstructorThrowPoints) { - if ($classReflection !== null && $constructorReflection !== null && $parametersAcceptor !== null) { - $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($classReflection->getName()), $expr->getArgs(), $scope); + if ($deferConstructorThrowPoints && $className !== null) { + if ($constructorReflection !== null && $parametersAcceptor !== null && $this->reflectionProvider->hasClass($className)) { + $classReflection = $this->reflectionProvider->getClass($className); + $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope); if ($constructorThrowPoint !== null) { $throwPoints[] = $constructorThrowPoint; } - } elseif (!$classFound) { + } elseif (!$this->reflectionProvider->hasClass($className)) { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } } @@ -226,7 +226,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } /** - * @return array{?MethodReflection, ?ParametersAcceptor, ?ClassReflection, bool, ImpurePoint[]} + * @return array{?MethodReflection, ?ParametersAcceptor, ImpurePoint[]} */ private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope): array { @@ -235,7 +235,6 @@ private function processConstructorReflection(string $className, New_ $expr, Mut $impurePoints = []; $classReflection = null; - $classFound = true; if ($this->reflectionProvider->hasClass($className)) { $classReflection = $this->reflectionProvider->getClass($className); if ($classReflection->hasConstructor()) { @@ -247,8 +246,6 @@ private function processConstructorReflection(string $className, New_ $expr, Mut $constructorReflection->getNamedArgumentsVariants(), ); } - } else { - $classFound = false; } if ($constructorReflection !== null) { @@ -272,7 +269,7 @@ private function processConstructorReflection(string $className, New_ $expr, Mut ); } - return [$constructorReflection, $parametersAcceptor, $classReflection, $classFound, $impurePoints]; + return [$constructorReflection, $parametersAcceptor, $impurePoints]; } /** From dc2324f18a2caa71da0d5c2b790be2ed39aff585 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 18 Mar 2026 17:36:30 +0100 Subject: [PATCH 4/8] Simplify --- src/Analyser/ExprHandler/NewHandler.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 965c4482be1..71b1ae2fdbc 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -81,17 +81,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $parametersAcceptor = null; $constructorReflection = null; + $className = null; + $classReflection = null; $hasYield = false; $throwPoints = []; + $deferConstructorThrowPoints = false; $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; - $className = null; - $deferConstructorThrowPoints = false; if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); - [$constructorReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); $deferConstructorThrowPoints = true; @@ -178,7 +179,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); $deferConstructorThrowPoints = true; } else { @@ -205,13 +206,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); if ($deferConstructorThrowPoints && $className !== null) { - if ($constructorReflection !== null && $parametersAcceptor !== null && $this->reflectionProvider->hasClass($className)) { - $classReflection = $this->reflectionProvider->getClass($className); + if ($constructorReflection !== null && $parametersAcceptor !== null && $classReflection !== null) { $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope); if ($constructorThrowPoint !== null) { $throwPoints[] = $constructorThrowPoint; } - } elseif (!$this->reflectionProvider->hasClass($className)) { + } elseif ($classReflection === null) { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } } @@ -226,7 +226,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } /** - * @return array{?MethodReflection, ?ParametersAcceptor, ImpurePoint[]} + * @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} */ private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope): array { @@ -269,7 +269,7 @@ private function processConstructorReflection(string $className, New_ $expr, Mut ); } - return [$constructorReflection, $parametersAcceptor, $impurePoints]; + return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; } /** From 8712fc8c3062ccfee65a7ee15409eb796d841e37 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 18 Mar 2026 18:04:50 +0100 Subject: [PATCH 5/8] Rework --- src/Analyser/ExprHandler/NewHandler.php | 32 ++++++++----------------- src/Analyser/InternalScopeFactory.php | 2 +- src/Analyser/MutatingScope.php | 4 ++-- src/Analyser/Scope.php | 2 ++ 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 71b1ae2fdbc..67cc17797d5 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -81,11 +81,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $parametersAcceptor = null; $constructorReflection = null; - $className = null; $classReflection = null; $hasYield = false; $throwPoints = []; - $deferConstructorThrowPoints = false; $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; @@ -94,7 +92,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); - $deferConstructorThrowPoints = true; if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; @@ -139,19 +136,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } else { $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); - $declaringClass = $constructorReflection->getDeclaringClass(); - $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($declaringClass->getName()), $expr->getArgs(), $scope); - if ($constructorThrowPoint !== null) { - $throwPoints[] = $constructorThrowPoint; - } - if (!$constructorReflection->hasSideEffects()->no()) { $certain = $constructorReflection->isPure()->no(); $impurePoints[] = new ImpurePoint( $scope, $expr, 'new', - sprintf('instantiation of class %s', $declaringClass->getDisplayName()), + sprintf('instantiation of class %s', $constructorReflection->getDeclaringClass()->getDisplayName()), $certain, ); } @@ -181,9 +172,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($className !== null) { [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); - $deferConstructorThrowPoints = true; } else { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); $impurePoints[] = new ImpurePoint( $scope, $expr, @@ -205,15 +194,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - if ($deferConstructorThrowPoints && $className !== null) { - if ($constructorReflection !== null && $parametersAcceptor !== null && $classReflection !== null) { - $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $classReflection, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope); - if ($constructorThrowPoint !== null) { - $throwPoints[] = $constructorThrowPoint; - } - } elseif ($classReflection === null) { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + if ($constructorReflection !== null && $parametersAcceptor !== null) { + $className ??= $constructorReflection->getDeclaringClass()->getName(); + $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope); + if ($constructorThrowPoint !== null) { + $throwPoints[] = $constructorThrowPoint; } + } elseif ($classReflection === null) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } return new ExpressionResult( @@ -275,7 +263,7 @@ private function processConstructorReflection(string $className, New_ $expr, Mut /** * @param list $args */ - private function getConstructorThrowPoint(MethodReflection $constructorReflection, ParametersAcceptor $parametersAcceptor, ClassReflection $classReflection, New_ $new, Name $className, array $args, MutatingScope $scope): ?InternalThrowPoint + private function getConstructorThrowPoint(MethodReflection $constructorReflection, ParametersAcceptor $parametersAcceptor, New_ $new, Name $className, array $args, MutatingScope $scope): ?InternalThrowPoint { $methodCall = new StaticCall($className, $constructorReflection->getName(), $args); $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); @@ -300,7 +288,7 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio return InternalThrowPoint::createExplicit($scope, $throwType, $new, true); } } elseif ($this->implicitThrows) { - if (!$classReflection->is(Throwable::class)) { + if (!$constructorReflection->getDeclaringClass()->is(Throwable::class)) { return InternalThrowPoint::createImplicit($scope, $methodCall); } } diff --git a/src/Analyser/InternalScopeFactory.php b/src/Analyser/InternalScopeFactory.php index 1d95299a693..3f32562ca40 100644 --- a/src/Analyser/InternalScopeFactory.php +++ b/src/Analyser/InternalScopeFactory.php @@ -15,7 +15,7 @@ interface InternalScopeFactory * @param array $expressionTypes * @param array $nativeExpressionTypes * @param array $conditionalExpressions - * @param list $inClosureBindScopeClasses + * @param list $inClosureBindScopeClasses * @param array $currentlyAssignedExpressions * @param array $currentlyAllowedUndefinedExpressions * @param list $inFunctionCallsStack diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e60242af43d..84875b15218 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -162,7 +162,7 @@ class MutatingScope implements Scope, NodeCallbackInvoker * @param callable(Node $node, Scope $scope): void|null $nodeCallback * @param array $expressionTypes * @param array $conditionalExpressions - * @param list $inClosureBindScopeClasses + * @param list $inClosureBindScopeClasses * @param array $currentlyAssignedExpressions * @param array $currentlyAllowedUndefinedExpressions * @param array $nativeExpressionTypes @@ -1820,7 +1820,7 @@ public function enterNamespace(string $namespaceName): self } /** - * @param list $scopeClasses + * @param list $scopeClasses */ public function enterClosureBind(?Type $thisType, ?Type $nativeThisType, array $scopeClasses): self { diff --git a/src/Analyser/Scope.php b/src/Analyser/Scope.php index 626e9db7ffa..6f616acd802 100644 --- a/src/Analyser/Scope.php +++ b/src/Analyser/Scope.php @@ -173,6 +173,8 @@ public function getScopeNativeType(Expr $expr): Type; * (they should already be fully qualified by the PHP parser's name resolver). * * Inside a Closure::bind() context, `self`/`static` resolve to the bound class. + * + * @return non-empty-string */ public function resolveName(Name $name): string; From a191a3b364eb55617e6544efd8dd6103b1c3b482 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 18 Mar 2026 18:22:30 +0100 Subject: [PATCH 6/8] Add tests --- .../Variables/DefinedVariableRuleTest.php | 6 +- .../Rules/Variables/data/bug-14323.php | 114 +++++++++++++++--- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index df0bfac7404..a05ca24efd7 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1442,15 +1442,15 @@ public function testBug14323(): void $this->analyse([__DIR__ . '/data/bug-14323.php'], [ [ 'Variable $command might not be defined.', - 40, + 24, ], [ 'Variable $command might not be defined.', - 66, + 50, ], [ 'Variable $command might not be defined.', - 118, + 119, ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-14323.php b/tests/PHPStan/Rules/Variables/data/bug-14323.php index f231ba06c48..18427b8f030 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-14323.php +++ b/tests/PHPStan/Rules/Variables/data/bug-14323.php @@ -9,22 +9,6 @@ class Process { public function __construct(array $a) {} } -class Process2 { - /** - * @param array $a - * @throws ProcessFailedException - */ - public function __construct(array $a) {} -} - -class Process3 { - /** - * @param array $a - * @throws void - */ - public function __construct(array $a) {} -} - abstract class DbCommand { /** @@ -77,6 +61,15 @@ public function handle() abstract public function getCommand(); } + +class Process2 { + /** + * @param array $a + * @throws ProcessFailedException + */ + public function __construct(array $a) {} +} + abstract class DbCommand3 { /** @@ -103,6 +96,14 @@ public function handle() abstract public function getCommand(); } +class Process3 { + /** + * @param array $a + * @throws void + */ + public function __construct(array $a) {} +} + abstract class DbCommand4 { /** @@ -128,3 +129,84 @@ public function handle() */ abstract public function getCommand(); } + +class Process4 { + /** + * @param array $a + * + * @throws \LogicException + */ + public function __construct(array $a) {} + + /** + * @throws ProcessFailedException + * @throws \LogicException + */ + public function mustRun(): int {} +} + +abstract class DbCommand5 +{ + /** + * @return int + */ + public function handle() + { + try { + (new Process4( + array_merge([$command = $this->getCommand()]), + ))->mustRun(); + } catch (ProcessFailedException $e) { + echo ("{$command} not found in path."); + + return 1; + } + + return 0; + } + + /** + * @return string + */ + abstract public function getCommand(); +} + +class Process5 { + /** + * @param array $a + * + * @throws \LogicException + */ + public function __construct(array $a) {} + + /** + * @throws ProcessFailedException + */ + public function mustRun(): int {} +} + +abstract class DbCommand6 +{ + /** + * @return int + */ + public function handle() + { + try { + (new Process5( + array_merge([$command = $this->getCommand()]), + ))->mustRun(); + } catch (ProcessFailedException $e) { + echo ("{$command} not found in path."); + + return 1; + } + + return 0; + } + + /** + * @return string + */ + abstract public function getCommand(); +} From 64e339be7460ba006d8c97fb4632dbc243497066 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 17:39:32 +0000 Subject: [PATCH 7/8] Fix catch block scope for union throw types with direct match When a method declares `@throws A|B` and a catch block catches `A`, the check for direct explicit non-throw match was using `$catchType->isSuperTypeOf($throwType)->yes()` which returns `maybe` for union throw types (since the catch doesn't cover `B`). This caused implicit throw points to be included unnecessarily, leading to false "variable might not be defined" reports. The fix decomposes union throw types and checks each member individually, so `A` in `A|B` is recognized as a direct match for `catch (A)`. Non-union types retain the original behavior, preserving correctness for cases like `@throws RuntimeException` caught as `PDOException`. Fixes inconsistency between DbCommand5 and DbCommand6 test cases. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ce853bd980c..6f70b06ee02 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1896,8 +1896,13 @@ public function processStmtNode( && !($throwNode instanceof Node\Stmt\Expression && $throwNode->expr instanceof Expr\Throw_) ) { $onlyExplicitIsThrow = false; - if ($catchTypeItem->isSuperTypeOf($throwPoint->getType())->yes()) { - $hasDirectExplicitNonThrowMatch = true; + $throwType = $throwPoint->getType(); + $innerTypes = $throwType instanceof UnionType ? $throwType->getTypes() : [$throwType]; + foreach ($innerTypes as $innerThrowType) { + if ($catchTypeItem->isSuperTypeOf($innerThrowType)->yes()) { + $hasDirectExplicitNonThrowMatch = true; + break; + } } } $matchingThrowPoints[$throwPointIndex] = $throwPoint; From 8728faec3b1ac43a677ee5c5773a4ee781e9461b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 18 Mar 2026 19:03:34 +0100 Subject: [PATCH 8/8] Fix test --- src/Analyser/NodeScopeResolver.php | 12 ++---------- tests/PHPStan/Rules/Variables/data/bug-9349.php | 6 +++--- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 6f70b06ee02..369f8e8eda2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1878,7 +1878,6 @@ public function processStmtNode( // explicit only $onlyExplicitIsThrow = true; - $hasDirectExplicitNonThrowMatch = false; if (count($matchingThrowPoints) === 0) { foreach ($throwPoints as $throwPointIndex => $throwPoint) { foreach ($catchTypes as $catchTypeIndex => $catchTypeItem) { @@ -1896,22 +1895,15 @@ public function processStmtNode( && !($throwNode instanceof Node\Stmt\Expression && $throwNode->expr instanceof Expr\Throw_) ) { $onlyExplicitIsThrow = false; - $throwType = $throwPoint->getType(); - $innerTypes = $throwType instanceof UnionType ? $throwType->getTypes() : [$throwType]; - foreach ($innerTypes as $innerThrowType) { - if ($catchTypeItem->isSuperTypeOf($innerThrowType)->yes()) { - $hasDirectExplicitNonThrowMatch = true; - break; - } - } } + $matchingThrowPoints[$throwPointIndex] = $throwPoint; } } } // implicit only - if (count($matchingThrowPoints) === 0 || $onlyExplicitIsThrow || !$hasDirectExplicitNonThrowMatch) { + if (count($matchingThrowPoints) === 0 || $onlyExplicitIsThrow) { foreach ($throwPoints as $throwPointIndex => $throwPoint) { if ($throwPoint->isExplicit()) { continue; diff --git a/tests/PHPStan/Rules/Variables/data/bug-9349.php b/tests/PHPStan/Rules/Variables/data/bug-9349.php index 44ed0202118..31dcfc1e2b0 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-9349.php +++ b/tests/PHPStan/Rules/Variables/data/bug-9349.php @@ -6,7 +6,7 @@ class HelloWorld { public function test(): void { - global $pdo; + $pdo = new \PDO('123'); try { $this->maybeThrows(); @@ -36,7 +36,7 @@ class HelloWorld2 { public function test2(): void { - global $pdo; + $pdo = new \PDO('123'); try { $this->maybeThrows2(); @@ -65,7 +65,7 @@ class HelloWorld3 { public function test3(): void { - global $pdo; + $pdo = new \PDO('123'); try { $this->maybeThrows3();