From 418ecc5bb7f6823630ba5f42f9e997ddfa0a13da Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 2 Mar 2026 06:32:41 +0000 Subject: [PATCH 1/3] Fix assignment inside match arm condition not recognized - Variable assignments in match arm conditions (e.g. `is_dir($baseDir = ...)`) were lost after commit 3beb8c626 replaced processExprNode with filterByTruthyValue - filterByTruthyValue only narrows types without walking the AST, so assignments within condition expressions were not discovered for the arm body scope - Fix transfers newly-defined variables from the condition processing scope to the body scope after applying truthiness filtering - New regression test in tests/PHPStan/Rules/Variables/data/bug-13981.php Closes https://github.com/phpstan/phpstan/issues/13981 --- src/Analyser/NodeScopeResolver.php | 26 +++++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 11 ++++++++ .../Rules/Variables/data/bug-13981.php | 15 +++++++++++ 3 files changed, 52 insertions(+) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13981.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 677aa31845..606f28b4e0 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -208,6 +208,7 @@ use UnhandledMatchError; use function array_fill_keys; use function array_filter; +use function array_flip; use function array_key_exists; use function array_key_last; use function array_keys; @@ -4386,6 +4387,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $filteringExprs = []; $armCondScope = $matchScope; $condNodes = []; + $armCondResultScope = $matchScope; foreach ($arm->conds as $j => $armCond) { if (isset($armCondsToSkip[$i][$j])) { continue; @@ -4407,6 +4409,30 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); $bodyScope = $matchScope->filterByTruthyValue($filteringExpr); + $condResultScope = $armCondResultScope; + $matchScopeKnownVars = array_flip(array_merge($matchScope->getDefinedVariables(), $matchScope->getMaybeDefinedVariables())); + foreach ($condResultScope->getDefinedVariables() as $varName) { + if (isset($matchScopeKnownVars[$varName])) { + continue; + } + $bodyScope = $bodyScope->assignVariable( + $varName, + $condResultScope->getVariableType($varName), + $condResultScope->getNativeType(new Variable($varName)), + $condResultScope->hasVariableType($varName), + ); + } + foreach ($condResultScope->getMaybeDefinedVariables() as $varName) { + if (isset($matchScopeKnownVars[$varName])) { + continue; + } + $bodyScope = $bodyScope->assignVariable( + $varName, + $condResultScope->getVariableType($varName), + $condResultScope->getNativeType(new Variable($varName)), + $condResultScope->hasVariableType($varName), + ); + } $matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 112a57d4cc..af3437d512 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1257,4 +1257,15 @@ public function testBug12944(): void $this->analyse([__DIR__ . '/data/bug-12944.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug13981(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-13981.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13981.php b/tests/PHPStan/Rules/Variables/data/bug-13981.php new file mode 100644 index 0000000000..c9efb80e38 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13981.php @@ -0,0 +1,15 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug13981; + +function foo(): string +{ + $path = match (true) { + is_dir($baseDir = dirname(__DIR__).'/lang') => $baseDir, + default => '/translations', + }; + + return $path; +} From a9fb98beea33a66de3eb9ae1ab20f205c4196c54 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 4 Mar 2026 00:37:48 +0100 Subject: [PATCH 2/3] Rework --- src/Analyser/NodeScopeResolver.php | 36 ++++----------- .../Variables/DefinedVariableRuleTest.php | 21 ++++++--- .../Rules/Variables/data/bug-13981.php | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 606f28b4e0..d5c3aa02c1 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4388,6 +4388,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $armCondScope = $matchScope; $condNodes = []; $armCondResultScope = $matchScope; + $bodyScope = null; foreach ($arm->conds as $j => $armCond) { if (isset($armCondsToSkip[$i][$j])) { continue; @@ -4403,36 +4404,17 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } - $armCondScope = $armCondResult->getScope()->filterByFalseyValue($armCondExpr); + $armCondScope = $armCondResultScope->filterByFalseyValue($armCondExpr); + if ($bodyScope === null) { + $bodyScope = $armCondResultScope->filterByTruthyValue($armCondExpr); + } else { + $bodyScope = $bodyScope->mergeWith($armCondResultScope->filterByTruthyValue($armCondExpr)); + } $filteringExprs[] = $armCond; } $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); - $bodyScope = $matchScope->filterByTruthyValue($filteringExpr); - $condResultScope = $armCondResultScope; - $matchScopeKnownVars = array_flip(array_merge($matchScope->getDefinedVariables(), $matchScope->getMaybeDefinedVariables())); - foreach ($condResultScope->getDefinedVariables() as $varName) { - if (isset($matchScopeKnownVars[$varName])) { - continue; - } - $bodyScope = $bodyScope->assignVariable( - $varName, - $condResultScope->getVariableType($varName), - $condResultScope->getNativeType(new Variable($varName)), - $condResultScope->hasVariableType($varName), - ); - } - foreach ($condResultScope->getMaybeDefinedVariables() as $varName) { - if (isset($matchScopeKnownVars[$varName])) { - continue; - } - $bodyScope = $bodyScope->assignVariable( - $varName, - $condResultScope->getVariableType($varName), - $condResultScope->getNativeType(new Variable($varName)), - $condResultScope->hasVariableType($varName), - ); - } + $bodyScope ??= $matchScope->filterByTruthyValue($filteringExpr); $matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); @@ -4449,7 +4431,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); - $matchScope = $matchScope->filterByFalseyValue($filteringExpr); + $matchScope = $armCondScope->filterByFalseyValue($filteringExpr); } if (!$hasDefaultCond && !$hasAlwaysTrueCond && $condType->isBoolean()->yes() && $condType->isConstantScalarValue()->yes()) { diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index af3437d512..1a9cc01ae1 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1236,36 +1236,45 @@ public function testBug10909(): void } #[RequiresPhp('>= 8.0')] - public function testBug7705(): void + public function testBug13981(): void { $this->cliArgumentsVariablesRegistered = true; $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-7705.php'], []); + $this->analyse([__DIR__ . '/data/bug-13981.php'], [ + [ + 'Undefined variable: $baseDir', + 34, + ], + [ + 'Variable $baseDir might not be defined.', + 46, + ], + ]); } #[RequiresPhp('>= 8.0')] - public function testBug12944(): void + public function testBug7705(): void { $this->cliArgumentsVariablesRegistered = true; $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-12944.php'], []); + $this->analyse([__DIR__ . '/data/bug-7705.php'], []); } #[RequiresPhp('>= 8.0')] - public function testBug13981(): void + public function testBug12944(): void { $this->cliArgumentsVariablesRegistered = true; $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-13981.php'], []); + $this->analyse([__DIR__ . '/data/bug-12944.php'], []); } } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13981.php b/tests/PHPStan/Rules/Variables/data/bug-13981.php index c9efb80e38..336f0c2e69 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-13981.php +++ b/tests/PHPStan/Rules/Variables/data/bug-13981.php @@ -13,3 +13,49 @@ function foo(): string return $path; } + +function foo2(): string +{ + if (rand(0, 1)) { + $baseDir = ''; + } + + $path = match (true) { + is_dir($baseDir = dirname(__DIR__).'/lang') => $baseDir, + default => '/translations', + }; + + return $path; +} + +function foo3(): string +{ + $path = match (true) { + is_dir(dirname(__DIR__).'/lang2') => $baseDir, + is_dir($baseDir = dirname(__DIR__).'/lang') => $baseDir, + default => '/translations', + }; + + return $path; +} + +function foo4(): string +{ + $path = match (true) { + is_dir(dirname(__DIR__).'/lang2'), + is_dir($baseDir = dirname(__DIR__).'/lang') => $baseDir, + default => '/translations', + }; + + return $path; +} + +function foo5(): string +{ + $path = match (true) { + is_dir($baseDir = dirname(__DIR__).'/lang') => '$baseDir', + default => $baseDir, + }; + + return $path; +} From d24d99c1f9cf9344231fc0913dbc5a4311ecdd54 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 4 Mar 2026 00:38:33 +0100 Subject: [PATCH 3/3] Fix --- src/Analyser/NodeScopeResolver.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d5c3aa02c1..0799c83f3c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -208,7 +208,6 @@ use UnhandledMatchError; use function array_fill_keys; use function array_filter; -use function array_flip; use function array_key_exists; use function array_key_last; use function array_keys;