From 3ff493205701094be96fdcf70f0de3958457b4ea Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:14:47 +0000 Subject: [PATCH 1/3] Fix phpstan/phpstan#14458: Conditional variable incorrectly reported as never defined - Skip No-certainty conditional expressions in Pass 2 (supertype match) of filterBySpecifiedTypes - The supertype match could incorrectly resolve only the "undefined" branch of a conditional variable, marking it as never defined - New regression test in tests/PHPStan/Rules/Variables/data/bug-14458.php --- src/Analyser/MutatingScope.php | 3 ++ .../Rules/Variables/NullCoalesceRuleTest.php | 5 ++ .../Rules/Variables/data/bug-14458.php | 50 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-14458.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 94510cd3a66..ddd6bd0211b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3238,6 +3238,9 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self // Pass 2: Supertype match. Only runs when Pass 1 found no exact match for this expression. foreach ($conditionalExpressions as $conditionalExpression) { + if ($conditionalExpression->getTypeHolder()->getCertainty()->no()) { + continue; + } foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( !array_key_exists($holderExprString, $specifiedExpressions) diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index d6a06d85810..88af910f2b5 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -387,6 +387,11 @@ public function testBug4846(): void ]); } + public function testBug14458(): void + { + $this->analyse([__DIR__ . '/data/bug-14458.php'], []); + } + public function testBug14393(): void { $this->analyse([__DIR__ . '/data/bug-14393.php'], [ diff --git a/tests/PHPStan/Rules/Variables/data/bug-14458.php b/tests/PHPStan/Rules/Variables/data/bug-14458.php new file mode 100644 index 00000000000..552f883e867 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14458.php @@ -0,0 +1,50 @@ + $payload + * @return array + */ + public function doFoo(array $payload): array + { + if ($payload['a'] !== 'b') { + throw new \Exception(); + } + + if (isset($payload['c'])) { + $c = array_values(array_map(static fn (array $cd) => $cd[0], $payload['c'])); + } + + $convertedPriceWithVat = null; + if (array_key_exists('cpwv', $payload)) { + $convertedAmount = (float) $payload['cpwv']['awv']; + + } + + return [ + $payload['cf'], + $payload['n'], + $payload['d'] ?? null, + $payload['mb'] ?? null, + $payload['cr'], + new DateTime($payload['p']), + $payload['pn'], + $payload['pd'] ?? null, + $payload['piu'] ?? null, + $convertedPriceWithVat, + $payload['vi'], + $payload['pi'], + $payload['user']['name'] ?? null, + $payload['user']['phone'] ?? null, + $payload['ac'] ?? null, + $c ?? null, + ]; + } + +} From 19ec2313b2537a4d27fcf10b744593bda02dde60 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 13 Apr 2026 12:32:35 +0000 Subject: [PATCH 2/3] Add comment explaining why Pass 1 does not need No-certainty check Address review feedback: Pass 1 uses exact matching which cannot produce the asymmetry that causes the bug in Pass 2. The supertype match in Pass 2 is inherently asymmetric - a broader condition type (the "undefined" branch) is more likely to be a supertype of a narrowed type than a narrower condition type (the "defined" branch). Exact matching has no such asymmetry. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index ddd6bd0211b..21c860e63cc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3237,6 +3237,14 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } // Pass 2: Supertype match. Only runs when Pass 1 found no exact match for this expression. + // Skip No-certainty holders here because supertype matching is asymmetric: + // a broader condition type (the "undefined" branch) is more likely to match + // as a supertype of a narrowed specified type than a narrower condition type + // (the "defined" branch), causing conditionally-defined variables to be + // incorrectly resolved as never defined. + // Pass 1 does not need this check because exact matching cannot produce + // this asymmetry - if the specified type exactly equals the No branch's + // condition, the scope genuinely corresponds to the "undefined" state. foreach ($conditionalExpressions as $conditionalExpression) { if ($conditionalExpression->getTypeHolder()->getCertainty()->no()) { continue; From b23f08f361d9fb535ebb337663ec9d825c8c8818 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 13 Apr 2026 14:48:28 +0200 Subject: [PATCH 3/3] Revert "Add comment explaining why Pass 1 does not need No-certainty check" This reverts commit 19ec2313b2537a4d27fcf10b744593bda02dde60. --- src/Analyser/MutatingScope.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 21c860e63cc..ddd6bd0211b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3237,14 +3237,6 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } // Pass 2: Supertype match. Only runs when Pass 1 found no exact match for this expression. - // Skip No-certainty holders here because supertype matching is asymmetric: - // a broader condition type (the "undefined" branch) is more likely to match - // as a supertype of a narrowed specified type than a narrower condition type - // (the "defined" branch), causing conditionally-defined variables to be - // incorrectly resolved as never defined. - // Pass 1 does not need this check because exact matching cannot produce - // this asymmetry - if the specified type exactly equals the No branch's - // condition, the scope genuinely corresponds to the "undefined" state. foreach ($conditionalExpressions as $conditionalExpression) { if ($conditionalExpression->getTypeHolder()->getCertainty()->no()) { continue;