From f6ed6bdc744809c11d5c025c5da541529dc08c3a Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:58:16 +0000 Subject: [PATCH 1/5] Fix incorrect type narrowing with dependent types in scope merging - Fixed createConditionalExpressions() in MutatingScope creating incorrect conditional type guards when the guard type overlaps with the other branch's type for the same expression - Added check: skip creating conditional expression when the guard type is a supertype of the other branch's type (isSuperTypeOf is not no), meaning the guard cannot uniquely identify which branch was taken - New regression test in tests/PHPStan/Analyser/nsrt/bug-14411.php Fixes phpstan/phpstan#14411 --- .../ExprHandler/MethodCallHandler.php | 12 ++-- .../ExprHandler/StaticCallHandler.php | 3 +- src/Analyser/MutatingScope.php | 72 +++++-------------- src/Analyser/NodeScopeResolver.php | 6 +- src/Analyser/TypeSpecifier.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14411.php | 34 +++++++++ tests/PHPStan/Analyser/nsrt/bug-5051.php | 6 +- .../Variables/DefinedVariableRuleTest.php | 57 +++++++++------ .../Rules/Variables/data/bug-12992.php | 40 ----------- .../Rules/Variables/data/bug-14227.php | 19 ----- .../Rules/Variables/data/dynamic-access.php | 12 ++-- 11 files changed, 108 insertions(+), 155 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14411.php delete mode 100644 tests/PHPStan/Rules/Variables/data/bug-12992.php delete mode 100644 tests/PHPStan/Rules/Variables/data/bug-14227.php diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 56af96b2ed6..826d757a20a 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -153,22 +153,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $argsResult->getScope(); if ($methodReflection !== null) { - $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); - if ($methodThrowPoint !== null) { - $throwPoints[] = $methodThrowPoint; + if ($parametersAcceptor !== null) { + $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); + if ($methodThrowPoint !== null) { + $throwPoints[] = $methodThrowPoint; + } } if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) { $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); - } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) { + } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin() && $parametersAcceptor !== null) { $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), $parametersAcceptor->getReturnType(), new MixedType(), ); } - if (!$methodReflection->isStatic()) { + if ($parametersAcceptor !== null && !$methodReflection->isStatic()) { $selfOutType = $methodReflection->getSelfOutType(); if ($selfOutType !== null) { $scope = $scope->assignExpression( diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 4e34e0991d9..2c06152b2cf 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -198,7 +198,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $argsResult->getScope(); $scopeFunction = $scope->getFunction(); - if ($methodReflection !== null) { + if ($methodReflection !== null && $parametersAcceptor !== null) { $methodThrowPoint = $this->getStaticMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; @@ -221,6 +221,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ( $methodReflection !== null && $this->rememberPossiblyImpureFunctionValues + && $parametersAcceptor !== null && $scope->isInClass() && $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName()) && $methodReflection->hasSideEffects()->maybe() diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 136e2023ba2..f5e6b970667 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3205,7 +3205,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } else { $scope = $scope->removeTypeFromExpression($expr, $type); } - $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); + $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getType($expr)); } $conditions = []; @@ -3337,7 +3337,7 @@ public function isInFirstLevelStatement(): bool return $this->inFirstLevelStatement; } - public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self + public function mergeWith(?self $otherScope): self { if ($otherScope === null || $this === $otherScope) { return $this; @@ -3347,18 +3347,6 @@ public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = $mergedExpressionTypes = $this->mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes); $conditionalExpressions = $this->intersectConditionalExpressions($otherScope->conditionalExpressions); - if ($preserveVacuousConditionals) { - $conditionalExpressions = $this->preserveVacuousConditionalExpressions( - $conditionalExpressions, - $this->conditionalExpressions, - $theirExpressionTypes, - ); - $conditionalExpressions = $this->preserveVacuousConditionalExpressions( - $conditionalExpressions, - $otherScope->conditionalExpressions, - $ourExpressionTypes, - ); - } $conditionalExpressions = $this->createConditionalExpressions( $conditionalExpressions, $ourExpressionTypes, @@ -3464,48 +3452,6 @@ private function intersectConditionalExpressions(array $otherConditionalExpressi return $newConditionalExpressions; } - /** - * @param array $currentConditionalExpressions - * @param array $sourceConditionalExpressions - * @param array $otherExpressionTypes - * @return array - */ - private function preserveVacuousConditionalExpressions( - array $currentConditionalExpressions, - array $sourceConditionalExpressions, - array $otherExpressionTypes, - ): array - { - foreach ($sourceConditionalExpressions as $exprString => $holders) { - foreach ($holders as $key => $holder) { - if (isset($currentConditionalExpressions[$exprString][$key])) { - continue; - } - - $typeHolder = $holder->getTypeHolder(); - if ($typeHolder->getCertainty()->no() && !$typeHolder->getExpr() instanceof Variable) { - continue; - } - - foreach ($holder->getConditionExpressionTypeHolders() as $guardExprString => $guardTypeHolder) { - if (!array_key_exists($guardExprString, $otherExpressionTypes)) { - continue; - } - - $otherType = $otherExpressionTypes[$guardExprString]->getType(); - $guardType = $guardTypeHolder->getType(); - - if ($otherType->isSuperTypeOf($guardType)->no()) { - $currentConditionalExpressions[$exprString][$key] = $holder; - break; - } - } - } - } - - return $currentConditionalExpressions; - } - /** * @param array $newConditionalExpressions * @param array $existingConditionalExpressions @@ -3603,6 +3549,13 @@ private function createConditionalExpressions( } foreach ($variableTypeGuards as $guardExprString => $guardHolder) { + if ( + array_key_exists($guardExprString, $theirExpressionTypes) + && $theirExpressionTypes[$guardExprString]->getCertainty()->yes() + && !$guardHolder->getType()->isSuperTypeOf($theirExpressionTypes[$guardExprString]->getType())->no() + ) { + continue; + } $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder); $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; } @@ -3614,6 +3567,13 @@ private function createConditionalExpressions( } foreach ($typeGuards as $guardExprString => $guardHolder) { + if ( + array_key_exists($guardExprString, $theirExpressionTypes) + && $theirExpressionTypes[$guardExprString]->getCertainty()->yes() + && !$guardHolder->getType()->isSuperTypeOf($theirExpressionTypes[$guardExprString]->getType())->no() + ) { + continue; + } $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo())); $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 001c8b5b368..560a994af49 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1146,7 +1146,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); $branchScope = $branchScopeStatementResult->getScope(); - $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope, true); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope); $alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating(); if (count($branchScopeStatementResult->getEndStatements()) > 0) { $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); @@ -1170,7 +1170,7 @@ public function processStmtNode( if ($stmt->else === null) { if (!$ifAlwaysTrue && !$lastElseIfConditionIsTrue) { - $finalScope = $scope->mergeWith($finalScope, true); + $finalScope = $scope->mergeWith($finalScope); $alwaysTerminating = false; } } else { @@ -1182,7 +1182,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); $branchScope = $branchScopeStatementResult->getScope(); - $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope, true); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope); $alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating(); if (count($branchScopeStatementResult->getEndStatements()) > 0) { $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c433..9d06e8d6fbb 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1896,7 +1896,7 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun } } - if ($assertions === null || $assertions->getAll() === []) { + if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) { return null; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14411.php b/tests/PHPStan/Analyser/nsrt/bug-14411.php new file mode 100644 index 00000000000..753532ed59a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14411.php @@ -0,0 +1,34 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14411; + +use function PHPStan\Testing\assertType; + +/** @phpstan-impure */ +function get_mixed(): mixed { + return random_int(0, 1) ? 'foo' : null; +} + +/** @phpstan-impure */ +function get_optional_int(): ?int { + return random_int(0, 1) ? 42 : null; +} + +function (): void { + $a = get_mixed(); + + if ($a !== null) { + $b = $a; + } + else { + $b = get_optional_int(); + } + if ($b !== null) { + assertType('mixed', $a); + if ($a === null) { + echo 'this is absolutely possible'; + } + } +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-5051.php b/tests/PHPStan/Analyser/nsrt/bug-5051.php index 6c3e80dce11..fff7211d7a2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5051.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5051.php @@ -59,7 +59,7 @@ public function testWithBooleans($data): void assertType('1|2|3|10', $data); assertType('bool', $update); } else { - assertType('1|2', $data); + assertType('1|2|3|10', $data); assertType('bool', $update); } @@ -81,7 +81,7 @@ public function testWithBooleans($data): void if ($data === 3) { assertType('bool', $update); - assertType('true', $foo); + assertType('bool', $foo); } else { assertType('bool', $update); assertType('bool', $foo); @@ -89,7 +89,7 @@ public function testWithBooleans($data): void if ($data === 1 || $data === 2) { assertType('bool', $update); - assertType('false', $foo); + assertType('bool', $foo); } else { assertType('bool', $update); assertType('bool', $foo); diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index b6161df2333..a05ca24efd7 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -911,7 +911,12 @@ public function testBug4173(): void $this->polluteScopeWithLoopInitialAssignments = false; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-4173.php'], []); + $this->analyse([__DIR__ . '/data/bug-4173.php'], [ + [ + 'Variable $value might not be defined.', // could be fixed + 30, + ], + ]); } public function testBug5805(): void @@ -1114,13 +1119,29 @@ public function testDynamicAccess(): void 18, ], [ - 'Undefined variable: $bar', + 'Variable $foo might not be defined.', + 36, + ], + [ + 'Variable $foo might not be defined.', + 37, + ], + [ + 'Variable $bar might not be defined.', 38, ], [ - 'Undefined variable: $foo', + 'Variable $bar might not be defined.', + 40, + ], + [ + 'Variable $foo might not be defined.', 41, ], + [ + 'Variable $bar might not be defined.', + 42, + ], [ 'Undefined variable: $buz', 44, @@ -1137,6 +1158,14 @@ public function testDynamicAccess(): void 'Undefined variable: $buz', 49, ], + [ + 'Variable $bar might not be defined.', + 49, + ], + [ + 'Variable $foo might not be defined.', + 49, + ], [ 'Variable $foo might not be defined.', 50, @@ -1458,24 +1487,6 @@ public function testBug13920(): void $this->analyse([__DIR__ . '/data/bug-13920.php'], []); } - public function testBug12992(): void - { - $this->cliArgumentsVariablesRegistered = true; - $this->polluteScopeWithLoopInitialAssignments = false; - $this->checkMaybeUndefinedVariables = true; - $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-12992.php'], []); - } - - public function testBug14227(): void - { - $this->cliArgumentsVariablesRegistered = true; - $this->polluteScopeWithLoopInitialAssignments = false; - $this->checkMaybeUndefinedVariables = true; - $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-14227.php'], []); - } - public function testBug14117(): void { $this->cliArgumentsVariablesRegistered = true; @@ -1484,6 +1495,10 @@ public function testBug14117(): void $this->polluteScopeWithAlwaysIterableForeach = true; $this->analyse([__DIR__ . '/data/bug-14117.php'], [ + [ + 'Variable $value might not be defined.', + 33, + ], [ 'Variable $value might not be defined.', 49, diff --git a/tests/PHPStan/Rules/Variables/data/bug-12992.php b/tests/PHPStan/Rules/Variables/data/bug-12992.php deleted file mode 100644 index e022b6c8aa6..00000000000 --- a/tests/PHPStan/Rules/Variables/data/bug-12992.php +++ /dev/null @@ -1,40 +0,0 @@ - Date: Tue, 31 Mar 2026 22:19:49 +0000 Subject: [PATCH 2/5] Fix #14411 without reverting PR #5056 preserveVacuousConditionals Restore preserveVacuousConditionals from PR #5056 and add a targeted guard overlap check in createConditionalExpressions() to prevent creating ambiguous conditional expressions when the guard type overlaps with the other branch's type for the guard variable. This preserves the improved type narrowing from #5056 (bug #4173, #12992, #14227) while also fixing the incorrect dependent type narrowing reported in #14411. Co-Authored-By: Claude Opus 4.6 --- .../ExprHandler/MethodCallHandler.php | 12 ++-- .../ExprHandler/StaticCallHandler.php | 3 +- src/Analyser/MutatingScope.php | 58 ++++++++++++++++++- src/Analyser/NodeScopeResolver.php | 6 +- src/Analyser/TypeSpecifier.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-5051.php | 6 +- .../Variables/DefinedVariableRuleTest.php | 57 +++++++----------- .../Rules/Variables/data/bug-12992.php | 40 +++++++++++++ .../Rules/Variables/data/bug-14227.php | 19 ++++++ .../Rules/Variables/data/dynamic-access.php | 12 ++-- 10 files changed, 155 insertions(+), 60 deletions(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-12992.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-14227.php diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 826d757a20a..56af96b2ed6 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -153,24 +153,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $argsResult->getScope(); if ($methodReflection !== null) { - if ($parametersAcceptor !== null) { - $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); - if ($methodThrowPoint !== null) { - $throwPoints[] = $methodThrowPoint; - } + $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); + if ($methodThrowPoint !== null) { + $throwPoints[] = $methodThrowPoint; } if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) { $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); - } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin() && $parametersAcceptor !== null) { + } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) { $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), $parametersAcceptor->getReturnType(), new MixedType(), ); } - if ($parametersAcceptor !== null && !$methodReflection->isStatic()) { + if (!$methodReflection->isStatic()) { $selfOutType = $methodReflection->getSelfOutType(); if ($selfOutType !== null) { $scope = $scope->assignExpression( diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 2c06152b2cf..4e34e0991d9 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -198,7 +198,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $argsResult->getScope(); $scopeFunction = $scope->getFunction(); - if ($methodReflection !== null && $parametersAcceptor !== null) { + if ($methodReflection !== null) { $methodThrowPoint = $this->getStaticMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; @@ -221,7 +221,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ( $methodReflection !== null && $this->rememberPossiblyImpureFunctionValues - && $parametersAcceptor !== null && $scope->isInClass() && $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName()) && $methodReflection->hasSideEffects()->maybe() diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f5e6b970667..fd9682be0dc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3205,7 +3205,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } else { $scope = $scope->removeTypeFromExpression($expr, $type); } - $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getType($expr)); + $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } $conditions = []; @@ -3337,7 +3337,7 @@ public function isInFirstLevelStatement(): bool return $this->inFirstLevelStatement; } - public function mergeWith(?self $otherScope): self + public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self { if ($otherScope === null || $this === $otherScope) { return $this; @@ -3347,6 +3347,18 @@ public function mergeWith(?self $otherScope): self $mergedExpressionTypes = $this->mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes); $conditionalExpressions = $this->intersectConditionalExpressions($otherScope->conditionalExpressions); + if ($preserveVacuousConditionals) { + $conditionalExpressions = $this->preserveVacuousConditionalExpressions( + $conditionalExpressions, + $this->conditionalExpressions, + $theirExpressionTypes, + ); + $conditionalExpressions = $this->preserveVacuousConditionalExpressions( + $conditionalExpressions, + $otherScope->conditionalExpressions, + $ourExpressionTypes, + ); + } $conditionalExpressions = $this->createConditionalExpressions( $conditionalExpressions, $ourExpressionTypes, @@ -3452,6 +3464,48 @@ private function intersectConditionalExpressions(array $otherConditionalExpressi return $newConditionalExpressions; } + /** + * @param array $currentConditionalExpressions + * @param array $sourceConditionalExpressions + * @param array $otherExpressionTypes + * @return array + */ + private function preserveVacuousConditionalExpressions( + array $currentConditionalExpressions, + array $sourceConditionalExpressions, + array $otherExpressionTypes, + ): array + { + foreach ($sourceConditionalExpressions as $exprString => $holders) { + foreach ($holders as $key => $holder) { + if (isset($currentConditionalExpressions[$exprString][$key])) { + continue; + } + + $typeHolder = $holder->getTypeHolder(); + if ($typeHolder->getCertainty()->no() && !$typeHolder->getExpr() instanceof Variable) { + continue; + } + + foreach ($holder->getConditionExpressionTypeHolders() as $guardExprString => $guardTypeHolder) { + if (!array_key_exists($guardExprString, $otherExpressionTypes)) { + continue; + } + + $otherType = $otherExpressionTypes[$guardExprString]->getType(); + $guardType = $guardTypeHolder->getType(); + + if ($otherType->isSuperTypeOf($guardType)->no()) { + $currentConditionalExpressions[$exprString][$key] = $holder; + break; + } + } + } + } + + return $currentConditionalExpressions; + } + /** * @param array $newConditionalExpressions * @param array $existingConditionalExpressions diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 560a994af49..001c8b5b368 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1146,7 +1146,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); $branchScope = $branchScopeStatementResult->getScope(); - $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope, true); $alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating(); if (count($branchScopeStatementResult->getEndStatements()) > 0) { $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); @@ -1170,7 +1170,7 @@ public function processStmtNode( if ($stmt->else === null) { if (!$ifAlwaysTrue && !$lastElseIfConditionIsTrue) { - $finalScope = $scope->mergeWith($finalScope); + $finalScope = $scope->mergeWith($finalScope, true); $alwaysTerminating = false; } } else { @@ -1182,7 +1182,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints()); $branchScope = $branchScopeStatementResult->getScope(); - $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope); + $finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope, true); $alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating(); if (count($branchScopeStatementResult->getEndStatements()) > 0) { $endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements()); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9d06e8d6fbb..4f3e7f9c433 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1896,7 +1896,7 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun } } - if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) { + if ($assertions === null || $assertions->getAll() === []) { return null; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5051.php b/tests/PHPStan/Analyser/nsrt/bug-5051.php index fff7211d7a2..6c3e80dce11 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5051.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5051.php @@ -59,7 +59,7 @@ public function testWithBooleans($data): void assertType('1|2|3|10', $data); assertType('bool', $update); } else { - assertType('1|2|3|10', $data); + assertType('1|2', $data); assertType('bool', $update); } @@ -81,7 +81,7 @@ public function testWithBooleans($data): void if ($data === 3) { assertType('bool', $update); - assertType('bool', $foo); + assertType('true', $foo); } else { assertType('bool', $update); assertType('bool', $foo); @@ -89,7 +89,7 @@ public function testWithBooleans($data): void if ($data === 1 || $data === 2) { assertType('bool', $update); - assertType('bool', $foo); + assertType('false', $foo); } else { assertType('bool', $update); assertType('bool', $foo); diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index a05ca24efd7..b6161df2333 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -911,12 +911,7 @@ public function testBug4173(): void $this->polluteScopeWithLoopInitialAssignments = false; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-4173.php'], [ - [ - 'Variable $value might not be defined.', // could be fixed - 30, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-4173.php'], []); } public function testBug5805(): void @@ -1119,29 +1114,13 @@ public function testDynamicAccess(): void 18, ], [ - 'Variable $foo might not be defined.', - 36, - ], - [ - 'Variable $foo might not be defined.', - 37, - ], - [ - 'Variable $bar might not be defined.', + 'Undefined variable: $bar', 38, ], [ - 'Variable $bar might not be defined.', - 40, - ], - [ - 'Variable $foo might not be defined.', + 'Undefined variable: $foo', 41, ], - [ - 'Variable $bar might not be defined.', - 42, - ], [ 'Undefined variable: $buz', 44, @@ -1158,14 +1137,6 @@ public function testDynamicAccess(): void 'Undefined variable: $buz', 49, ], - [ - 'Variable $bar might not be defined.', - 49, - ], - [ - 'Variable $foo might not be defined.', - 49, - ], [ 'Variable $foo might not be defined.', 50, @@ -1487,6 +1458,24 @@ public function testBug13920(): void $this->analyse([__DIR__ . '/data/bug-13920.php'], []); } + public function testBug12992(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-12992.php'], []); + } + + public function testBug14227(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-14227.php'], []); + } + public function testBug14117(): void { $this->cliArgumentsVariablesRegistered = true; @@ -1495,10 +1484,6 @@ public function testBug14117(): void $this->polluteScopeWithAlwaysIterableForeach = true; $this->analyse([__DIR__ . '/data/bug-14117.php'], [ - [ - 'Variable $value might not be defined.', - 33, - ], [ 'Variable $value might not be defined.', 49, diff --git a/tests/PHPStan/Rules/Variables/data/bug-12992.php b/tests/PHPStan/Rules/Variables/data/bug-12992.php new file mode 100644 index 00000000000..e022b6c8aa6 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12992.php @@ -0,0 +1,40 @@ + Date: Tue, 31 Mar 2026 22:39:13 +0000 Subject: [PATCH 3/5] Fix regression: refine guard overlap check in createConditionalExpressions() The previous guard overlap check was too aggressive - it blocked creating conditional expressions in both loops of createConditionalExpressions(), including cases needed for variable certainty tracking. The fix: 1. Only apply the guard overlap check in the first loop (type narrowing), not the second loop (variable certainty/existence) 2. Additionally require that the guarded expression exists with certainty Yes in the other branch before skipping - this ensures we only skip when both branches define the variable (ambiguous type case), not when a variable is only defined in one branch (certainty tracking case) Adds regression test for the case reported by VincentLanglet where $order should be certainly defined after two sequential if-blocks that together cover all cases of a union type parameter. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 11 ++---- .../Analyser/nsrt/bug-14411-regression.php | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14411-regression.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fd9682be0dc..a43ba35dda5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3604,7 +3604,9 @@ private function createConditionalExpressions( foreach ($variableTypeGuards as $guardExprString => $guardHolder) { if ( - array_key_exists($guardExprString, $theirExpressionTypes) + array_key_exists($exprString, $theirExpressionTypes) + && $theirExpressionTypes[$exprString]->getCertainty()->yes() + && array_key_exists($guardExprString, $theirExpressionTypes) && $theirExpressionTypes[$guardExprString]->getCertainty()->yes() && !$guardHolder->getType()->isSuperTypeOf($theirExpressionTypes[$guardExprString]->getType())->no() ) { @@ -3621,13 +3623,6 @@ private function createConditionalExpressions( } foreach ($typeGuards as $guardExprString => $guardHolder) { - if ( - array_key_exists($guardExprString, $theirExpressionTypes) - && $theirExpressionTypes[$guardExprString]->getCertainty()->yes() - && !$guardHolder->getType()->isSuperTypeOf($theirExpressionTypes[$guardExprString]->getType())->no() - ) { - continue; - } $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo())); $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14411-regression.php b/tests/PHPStan/Analyser/nsrt/bug-14411-regression.php new file mode 100644 index 00000000000..31fb5421b07 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14411-regression.php @@ -0,0 +1,36 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14411Regression; + +use function PHPStan\Testing\assertType; +use function PHPStan\Testing\assertVariableCertainty; +use PHPStan\TrinaryLogic; + +interface OrderInterface {} + +class Event +{ + /** @return mixed */ + public function getSubject() + { + return new \stdClass(); + } +} + +function getOrder(Event|OrderInterface $event): OrderInterface +{ + if ($event instanceof Event) { + $order = $event->getSubject(); + assert($order instanceof OrderInterface); + } + + if ($event instanceof OrderInterface) { + $order = $event; + } + + assertVariableCertainty(TrinaryLogic::createYes(), $order); + + return $order; +} From 10c3ec46ab4df567fe95cdeeb95c2beebd322329 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 31 Mar 2026 22:57:14 +0000 Subject: [PATCH 4/5] Add non-regression tests for #11328, #10085, #14211 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-10085.php | 16 ++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-11328.php | 27 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14211.php | 25 +++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10085.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11328.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14211.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-10085.php b/tests/PHPStan/Analyser/nsrt/bug-10085.php new file mode 100644 index 00000000000..b6c30d3978e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10085.php @@ -0,0 +1,16 @@ + $a + * @param list $b + */ +function foo(array $a, array $b): void { + $foo = [...$a, ...$b]; + assertType('list', $foo); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11328.php b/tests/PHPStan/Analyser/nsrt/bug-11328.php new file mode 100644 index 00000000000..bf315fc73c6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11328.php @@ -0,0 +1,27 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug11328; + +use function PHPStan\Testing\assertType; + +enum Status: string { + case Live = 's1'; + case Expired = 's2'; +} + +/** @param Status[] $statuses */ +function check(array $statuses): void { + $fromDeadline = null; + $toDeadline = null; + if (in_array(Status::Live, $statuses, true)) { + $fromDeadline = (new \DateTimeImmutable())->setTime(23, 59, 59); + } + if (in_array(Status::Expired, $statuses, true)) { + assertType('DateTimeImmutable|null', $fromDeadline); + if ($fromDeadline === null) { + $toDeadline = (new \DateTimeImmutable())->setTime(0, 0, 0); + } + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14211.php b/tests/PHPStan/Analyser/nsrt/bug-14211.php new file mode 100644 index 00000000000..3ca6631631f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14211.php @@ -0,0 +1,25 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14211; + +use function PHPStan\Testing\assertType; + +/** + * @param array $array + */ +function foo(string $key, array $array): void { + if (array_key_exists($key, $array)) { + $value = $array[$key]; + assertType('int', $value); + } else { + $value = null; + } + + assertType('int|null', $value); + + if ($value !== null) { + assertType('bool', array_key_exists($key, $array)); // should be true, see phpstan/phpstan#14211 + } +} From a439851054433d8bdd2a9989a641bf2417d3f1aa Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 1 Apr 2026 01:03:15 +0200 Subject: [PATCH 5/5] Fix non regression test --- tests/PHPStan/Analyser/nsrt/bug-10085.php | 26 +++++++++++++++------ tests/PHPStan/Analyser/nsrt/bug-14211.php | 28 ++++++++++++----------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-10085.php b/tests/PHPStan/Analyser/nsrt/bug-10085.php index b6c30d3978e..aa8dcc3b8a9 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10085.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10085.php @@ -6,11 +6,23 @@ use function PHPStan\Testing\assertType; -/** - * @param list $a - * @param list $b - */ -function foo(array $a, array $b): void { - $foo = [...$a, ...$b]; - assertType('list', $foo); +class HelloWorld +{ + /** + * @param array $foo + * @param list $bar + */ + public function sayHello(array $foo, array $bar): void + { + $a = $foo; + if ($a === []) { + $a = $bar; + } + + if ($a === []) { + return; + } + + assertType('array', $foo); + } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14211.php b/tests/PHPStan/Analyser/nsrt/bug-14211.php index 3ca6631631f..b3ef9d987c2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14211.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14211.php @@ -6,20 +6,22 @@ use function PHPStan\Testing\assertType; -/** - * @param array $array - */ -function foo(string $key, array $array): void { - if (array_key_exists($key, $array)) { - $value = $array[$key]; - assertType('int', $value); - } else { - $value = null; - } +/** @param array $data */ +function DoSomithing(array $data): bool { + + if (!isset($data['x'])) + return false; - assertType('int|null', $value); + $m = isset($data['y']); - if ($value !== null) { - assertType('bool', array_key_exists($key, $array)); // should be true, see phpstan/phpstan#14211 + if ($m) { + assertType('true', $m); // ok: true } + assertType('bool', $m); // ok: bool + + if ($m) { + assertType('true', $m); // <-- should not be: NEVER + } + + return true; }