From a9392500a433b51dcacdf0437d52f1fa10c5e380 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:47:33 +0000 Subject: [PATCH] Fix phpstan/phpstan#14227: Variable might not be defined false positive with unset in mutually exclusive branch - Added preserveSafeConditionalExpressions() to MutatingScope to preserve conditional expressions through merges when one branch invalidates a variable (e.g. via unset()) but the guard condition is disjoint from the other branch's types - This also fixes related false positives for variables defined in different branches of if/elseif/else (bug-4173, dynamic-access test improvements) - Restricted preservation to simple Variable expressions to avoid stale method call narrowing - New regression test in tests/PHPStan/Rules/Variables/data/bug-14227.php --- src/Analyser/MutatingScope.php | 71 +++++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 42 +++++------ .../Rules/Variables/data/bug-14227.php | 35 +++++++++ 3 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-14227.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8de4e331b6..4a9239e07d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3952,6 +3952,8 @@ public function mergeWith(?self $otherScope): self $mergedExpressionTypes = $this->mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes); $conditionalExpressions = $this->intersectConditionalExpressions($otherScope->conditionalExpressions); + $conditionalExpressions = $this->preserveSafeConditionalExpressions($conditionalExpressions, $this->conditionalExpressions, $theirExpressionTypes); + $conditionalExpressions = $this->preserveSafeConditionalExpressions($conditionalExpressions, $otherScope->conditionalExpressions, $ourExpressionTypes); $conditionalExpressions = $this->createConditionalExpressions( $conditionalExpressions, $ourExpressionTypes, @@ -4051,6 +4053,75 @@ private function intersectConditionalExpressions(array $otherConditionalExpressi return $newConditionalExpressions; } + /** + * Preserve conditional expressions from one scope that were dropped by + * intersectConditionalExpressions because they don't exist in the other scope. + * + * This handles the case where a variable was invalidated (e.g. by unset()) in one + * branch, causing its conditional expressions to be removed. When the guard condition + * of a conditional expression is disjoint from the other scope's types for the guard + * variable, the conditional expression is still valid and should be preserved. + * + * @param array $currentConditionalExpressions + * @param array $scopeConditionalExpressions + * @param array $otherExpressionTypes + * @return array + */ + private function preserveSafeConditionalExpressions( + array $currentConditionalExpressions, + array $scopeConditionalExpressions, + array $otherExpressionTypes, + ): array + { + foreach ($scopeConditionalExpressions as $exprString => $holders) { + if (array_key_exists($exprString, $currentConditionalExpressions)) { + continue; + } + + if (array_key_exists($exprString, $otherExpressionTypes)) { + continue; + } + + if (count($holders) === 0) { + continue; + } + + $firstHolder = $holders[array_key_first($holders)]; + $subjectExpr = $firstHolder->getTypeHolder()->getExpr(); + if (!$subjectExpr instanceof Variable || !is_string($subjectExpr->name)) { + continue; + } + + $safeHolders = []; + foreach ($holders as $key => $holder) { + $safe = true; + foreach ($holder->getConditionExpressionTypeHolders() as $guardExprString => $guardHolder) { + if (!array_key_exists($guardExprString, $otherExpressionTypes)) { + $safe = false; + break; + } + if (!$otherExpressionTypes[$guardExprString]->getType()->isSuperTypeOf($guardHolder->getType())->no()) { + $safe = false; + break; + } + } + if (!$safe) { + continue; + } + + $safeHolders[$key] = $holder; + } + + if (count($safeHolders) <= 0) { + continue; + } + + $currentConditionalExpressions[$exprString] = $safeHolders; + } + + return $currentConditionalExpressions; + } + /** * @param array $newConditionalExpressions * @param array $existingConditionalExpressions diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index d6a4ce9a4c..101d208182 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, @@ -1400,4 +1379,19 @@ public function testBug14117(): void ]); } + public function testBug14227(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-14227.php'], [ + [ + 'Variable $value might not be defined.', + 33, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-14227.php b/tests/PHPStan/Rules/Variables/data/bug-14227.php new file mode 100644 index 0000000000..2755d3d61d --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14227.php @@ -0,0 +1,35 @@ +