From 7cca5236ffd09aa9eb856c5c2f32d0728f1bccd6 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:17:28 +0000 Subject: [PATCH 01/10] Fix incorrect type-narrowing with polluteScopeWithAlwaysIterableForeach - Fixed processAlwaysIterableForeachScopeWithoutPollute using stale conditional expressions from before the foreach body, which caused variable aliases (e.g. $current = $initial) to persist even after reassignment inside the foreach - Changed to use $finalScope->conditionalExpressions which properly reflects invalidated aliases from foreach body assignments - New regression test in tests/PHPStan/Analyser/data/bug-14446.php Closes https://github.com/phpstan/phpstan/issues/14446 --- src/Analyser/MutatingScope.php | 2 +- tests/PHPStan/Analyser/Bug14446Test.php | 36 +++++++++++++++++++++++ tests/PHPStan/Analyser/bug-14446.neon | 2 ++ tests/PHPStan/Analyser/data/bug-14446.php | 24 +++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/Bug14446Test.php create mode 100644 tests/PHPStan/Analyser/bug-14446.neon create mode 100644 tests/PHPStan/Analyser/data/bug-14446.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a43ba35dda5..44be2c9799e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3877,7 +3877,7 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope $this->getNamespace(), $expressionTypes, $nativeTypes, - $this->conditionalExpressions, + $finalScope->conditionalExpressions, $this->inClosureBindScopeClasses, $this->anonymousFunctionReflection, $this->inFirstLevelStatement, diff --git a/tests/PHPStan/Analyser/Bug14446Test.php b/tests/PHPStan/Analyser/Bug14446Test.php new file mode 100644 index 00000000000..84a421583e6 --- /dev/null +++ b/tests/PHPStan/Analyser/Bug14446Test.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-14446.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/bug-14446.neon b/tests/PHPStan/Analyser/bug-14446.neon new file mode 100644 index 00000000000..3ee516d3be6 --- /dev/null +++ b/tests/PHPStan/Analyser/bug-14446.neon @@ -0,0 +1,2 @@ +parameters: + polluteScopeWithAlwaysIterableForeach: false diff --git a/tests/PHPStan/Analyser/data/bug-14446.php b/tests/PHPStan/Analyser/data/bug-14446.php new file mode 100644 index 00000000000..a7308e54330 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14446.php @@ -0,0 +1,24 @@ + Date: Thu, 9 Apr 2026 14:42:01 +0000 Subject: [PATCH 02/10] Filter conditional expressions from foreach body in processAlwaysIterableForeachScopeWithoutPollute When using $finalScope->conditionalExpressions, conditional expressions referencing variables only defined inside the foreach body (like $key) could leak into the outer scope. When those conditional expressions fired (e.g. inside an if block), the foreach variable would be re-introduced with Yes certainty, causing false "Foreach overwrites $key" errors. Filter conditional expressions to only keep those where both the target expression and all condition expressions reference variables that existed before the foreach loop. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 22 +++++++++++++++++++- tests/PHPStan/Analyser/data/bug-14446.php | 25 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 44be2c9799e..213596f8a41 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3870,6 +3870,26 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope ); } + $conditionalExpressions = []; + foreach ($finalScope->conditionalExpressions as $conditionalExprString => $holders) { + if (!isset($this->expressionTypes[$conditionalExprString])) { + continue; + } + $filteredHolders = []; + foreach ($holders as $holder) { + foreach (array_keys($holder->getConditionExpressionTypeHolders()) as $holderExprString) { + if (!isset($this->expressionTypes[$holderExprString])) { + continue 2; + } + } + $filteredHolders[] = $holder; + } + if ($filteredHolders === []) { + continue; + } + $conditionalExpressions[$conditionalExprString] = $filteredHolders; + } + return $this->scopeFactory->create( $this->context, $this->isDeclareStrictTypes(), @@ -3877,7 +3897,7 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope $this->getNamespace(), $expressionTypes, $nativeTypes, - $finalScope->conditionalExpressions, + $conditionalExpressions, $this->inClosureBindScopeClasses, $this->anonymousFunctionReflection, $this->inFirstLevelStatement, diff --git a/tests/PHPStan/Analyser/data/bug-14446.php b/tests/PHPStan/Analyser/data/bug-14446.php index a7308e54330..1de4e195234 100644 --- a/tests/PHPStan/Analyser/data/bug-14446.php +++ b/tests/PHPStan/Analyser/data/bug-14446.php @@ -22,3 +22,28 @@ function test(bool $initial): void { assertType('bool', $initial); } + +/** + * @param mixed $value + */ +function testForeachKeyOverwrite($value): void { + if (is_array($value) && $value !== []) { + $hasOnlyStringKey = true; + foreach (array_keys($value) as $key) { + if (is_int($key)) { + $hasOnlyStringKey = false; + break; + } + } + + assertType('bool', $hasOnlyStringKey); + + if ($hasOnlyStringKey) { + // $key should not be in scope here with polluteScopeWithAlwaysIterableForeach: false + // Second foreach should not report "Foreach overwrites $key with its key variable" + foreach ($value as $key => $element) { + assertType('(int|string)', $key); + } + } + } +} From 53b917ababb286cc04302af0bbe8deb765e1468d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 9 Apr 2026 15:17:35 +0000 Subject: [PATCH 03/10] Fix conditional expression filtering in processAlwaysIterableForeachScopeWithoutPollute The previous approach (using $finalScope->conditionalExpressions filtered to $this variables) lost valid conditional expressions that existed in $this but not in $finalScope, causing false positives like "Call to an undefined method PhpParser\Node::getReturnType()". The correct approach: start from $this->conditionalExpressions (pre-foreach) and remove only entries where a condition variable's type changed between $this and $finalScope, indicating it was reassigned in the foreach body and the conditional expression is stale. When a type change is detected, the entire conditional expression key is removed (not individual holders) to avoid partial/inconsistent state. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 213596f8a41..53efe8d484f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3870,24 +3870,22 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope ); } - $conditionalExpressions = []; - foreach ($finalScope->conditionalExpressions as $conditionalExprString => $holders) { - if (!isset($this->expressionTypes[$conditionalExprString])) { - continue; - } - $filteredHolders = []; + $conditionalExpressions = $this->conditionalExpressions; + foreach ($this->conditionalExpressions as $conditionalExprString => $holders) { foreach ($holders as $holder) { - foreach (array_keys($holder->getConditionExpressionTypeHolders()) as $holderExprString) { + foreach ($holder->getConditionExpressionTypeHolders() as $holderExprString => $condTypeHolder) { + if (!isset($finalScope->expressionTypes[$holderExprString])) { + continue 2; + } if (!isset($this->expressionTypes[$holderExprString])) { continue 2; } + if (!$this->expressionTypes[$holderExprString]->getType()->equals($finalScope->expressionTypes[$holderExprString]->getType())) { + unset($conditionalExpressions[$conditionalExprString]); + continue 3; + } } - $filteredHolders[] = $holder; - } - if ($filteredHolders === []) { - continue; } - $conditionalExpressions[$conditionalExprString] = $filteredHolders; } return $this->scopeFactory->create( From 62b06ccd197857ba7878415527207628adc588e7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 07:07:09 +0000 Subject: [PATCH 04/10] Fix unused variable $condTypeHolder in processAlwaysIterableForeachScopeWithoutPollute Use array_keys() to iterate only over keys since the value is not used. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 53efe8d484f..e48d122a8d6 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3873,7 +3873,7 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope $conditionalExpressions = $this->conditionalExpressions; foreach ($this->conditionalExpressions as $conditionalExprString => $holders) { foreach ($holders as $holder) { - foreach ($holder->getConditionExpressionTypeHolders() as $holderExprString => $condTypeHolder) { + foreach (array_keys($holder->getConditionExpressionTypeHolders()) as $holderExprString) { if (!isset($finalScope->expressionTypes[$holderExprString])) { continue 2; } From 5c825e224dcecaa08afa1e39a042b1d6fd39860f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 07:42:55 +0000 Subject: [PATCH 05/10] Use intersectConditionalExpressions in processAlwaysIterableForeachScopeWithoutPollute Replace custom conditional expression filtering with intersectConditionalExpressions($finalScope->conditionalExpressions), which is the same mechanism mergeWith() uses in the $isIterableAtLeastOnce->maybe() code path. When the foreach body reassigns a variable (e.g. $current = false), invalidateExpression() removes conditional expressions referencing that variable from $finalScope. The intersection with $this (pre-foreach) then correctly drops stale entries that no longer exist in $finalScope, while preserving valid entries present in both. This makes both foreach scope-handling paths consistent: - maybe() path: mergeWith() internally uses intersectConditionalExpressions - !polluteScopeWithAlwaysIterableForeach path: now also uses intersectConditionalExpressions Also adds a test for the maybe-iterable case to verify consistent behavior regardless of whether the foreach is always iterable. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 20 +------------------- tests/PHPStan/Analyser/data/bug-14446.php | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e48d122a8d6..75dbfa339be 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3870,24 +3870,6 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope ); } - $conditionalExpressions = $this->conditionalExpressions; - foreach ($this->conditionalExpressions as $conditionalExprString => $holders) { - foreach ($holders as $holder) { - foreach (array_keys($holder->getConditionExpressionTypeHolders()) as $holderExprString) { - if (!isset($finalScope->expressionTypes[$holderExprString])) { - continue 2; - } - if (!isset($this->expressionTypes[$holderExprString])) { - continue 2; - } - if (!$this->expressionTypes[$holderExprString]->getType()->equals($finalScope->expressionTypes[$holderExprString]->getType())) { - unset($conditionalExpressions[$conditionalExprString]); - continue 3; - } - } - } - } - return $this->scopeFactory->create( $this->context, $this->isDeclareStrictTypes(), @@ -3895,7 +3877,7 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope $this->getNamespace(), $expressionTypes, $nativeTypes, - $conditionalExpressions, + $this->intersectConditionalExpressions($finalScope->conditionalExpressions), $this->inClosureBindScopeClasses, $this->anonymousFunctionReflection, $this->inFirstLevelStatement, diff --git a/tests/PHPStan/Analyser/data/bug-14446.php b/tests/PHPStan/Analyser/data/bug-14446.php index 1de4e195234..2096c3051fb 100644 --- a/tests/PHPStan/Analyser/data/bug-14446.php +++ b/tests/PHPStan/Analyser/data/bug-14446.php @@ -23,6 +23,25 @@ function test(bool $initial): void { assertType('bool', $initial); } +function testMaybeIterable(bool $initial): void { + $current = $initial; + + while (true) { + assertType('bool', $initial); + if (!$current) { + assertType('bool', $initial); + break; + } + + $items = rand() > 0 ? [1] : []; + foreach ($items as $item) { + $current = false; + } + } + + assertType('bool', $initial); +} + /** * @param mixed $value */ From 1196a5ef9cdb8834836e4701b8700441e77f4e79 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 08:07:06 +0000 Subject: [PATCH 06/10] Add rule tests for bug 14446 to verify no false positives Add StrictComparisonOfDifferentTypesRule test with polluteScopeWithAlwaysIterableForeach: false to ensure no "Strict comparison using === between false and true" error. Add integration-style rule test in Bug14446Test that runs full analysis with OverwriteVariablesWithForeachRule registered, verifying no "Foreach overwrites $key" false positive. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/Bug14446Test.php | 16 ++++++ tests/PHPStan/Analyser/bug-14446.neon | 6 ++ .../PHPStan/Analyser/data/bug-14446-rule.php | 57 +++++++++++++++++++ ...rictComparisonOfDifferentTypesRuleTest.php | 13 +++++ .../Rules/Comparison/data/bug-14446.php | 37 ++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 tests/PHPStan/Analyser/data/bug-14446-rule.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14446.php diff --git a/tests/PHPStan/Analyser/Bug14446Test.php b/tests/PHPStan/Analyser/Bug14446Test.php index 84a421583e6..c7692042069 100644 --- a/tests/PHPStan/Analyser/Bug14446Test.php +++ b/tests/PHPStan/Analyser/Bug14446Test.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use PHPStan\File\FileHelper; use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -33,4 +34,19 @@ public static function getAdditionalConfigFiles(): array ]; } + public function testRule(): void + { + $file = self::getContainer()->getByType(FileHelper::class)->normalizePath(__DIR__ . '/data/bug-14446-rule.php'); + + $analyser = self::getContainer()->getByType(Analyser::class); + $finalizer = self::getContainer()->getByType(AnalyserResultFinalizer::class); + $errors = $finalizer->finalize( + $analyser->analyse([$file], null, null, true), + false, + true, + )->getErrors(); + + $this->assertNoErrors($errors); + } + } diff --git a/tests/PHPStan/Analyser/bug-14446.neon b/tests/PHPStan/Analyser/bug-14446.neon index 3ee516d3be6..58913854694 100644 --- a/tests/PHPStan/Analyser/bug-14446.neon +++ b/tests/PHPStan/Analyser/bug-14446.neon @@ -1,2 +1,8 @@ parameters: polluteScopeWithAlwaysIterableForeach: false + +services: + - + class: PHPStan\Rules\ForeachLoop\OverwriteVariablesWithForeachRule + tags: + - phpstan.rules.rule diff --git a/tests/PHPStan/Analyser/data/bug-14446-rule.php b/tests/PHPStan/Analyser/data/bug-14446-rule.php new file mode 100644 index 00000000000..5fd2df5f4a2 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14446-rule.php @@ -0,0 +1,57 @@ + 0 ? [1] : []; + foreach ($items as $item) { + $current = false; + } + } + + var_dump($initial === true); +} + +/** + * @param mixed $value + */ +function testForeachKeyOverwrite($value): void { + if (is_array($value) && $value !== []) { + $hasOnlyStringKey = true; + foreach (array_keys($value) as $key) { + if (is_int($key)) { + $hasOnlyStringKey = false; + break; + } + } + + if ($hasOnlyStringKey) { + foreach ($value as $key => $element) { + } + } + } +} diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index aa632593c79..3cf6ab40ea6 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -20,6 +20,8 @@ class StrictComparisonOfDifferentTypesRuleTest extends RuleTestCase private bool $treatPhpDocTypesAsCertain = true; + private bool $polluteScopeWithAlwaysIterableForeach = true; + protected function getRule(): Rule { return new StrictComparisonOfDifferentTypesRule( @@ -36,6 +38,11 @@ protected function shouldTreatPhpDocTypesAsCertain(): bool return $this->treatPhpDocTypesAsCertain; } + protected function shouldPolluteScopeWithAlwaysIterableForeach(): bool + { + return $this->polluteScopeWithAlwaysIterableForeach; + } + public function testStrictComparison(): void { $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; @@ -1184,4 +1191,10 @@ public function testBug13421(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13421.php'], []); } + public function testBug14446(): void + { + $this->polluteScopeWithAlwaysIterableForeach = false; + $this->analyse([__DIR__ . '/data/bug-14446.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14446.php b/tests/PHPStan/Rules/Comparison/data/bug-14446.php new file mode 100644 index 00000000000..17ee20a8458 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14446.php @@ -0,0 +1,37 @@ + 0 ? [1] : []; + foreach ($items as $item) { + $current = false; + } + } + + var_dump($initial === true); +} From 19038be0ffc7d64d803dc5507cd342f1e2c2a9be Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 10 Apr 2026 10:18:24 +0200 Subject: [PATCH 07/10] Simplify --- tests/PHPStan/Analyser/Bug14446Test.php | 16 ------ .../PHPStan/Analyser/data/bug-14446-rule.php | 57 ------------------- 2 files changed, 73 deletions(-) delete mode 100644 tests/PHPStan/Analyser/data/bug-14446-rule.php diff --git a/tests/PHPStan/Analyser/Bug14446Test.php b/tests/PHPStan/Analyser/Bug14446Test.php index c7692042069..84a421583e6 100644 --- a/tests/PHPStan/Analyser/Bug14446Test.php +++ b/tests/PHPStan/Analyser/Bug14446Test.php @@ -2,7 +2,6 @@ namespace PHPStan\Analyser; -use PHPStan\File\FileHelper; use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -34,19 +33,4 @@ public static function getAdditionalConfigFiles(): array ]; } - public function testRule(): void - { - $file = self::getContainer()->getByType(FileHelper::class)->normalizePath(__DIR__ . '/data/bug-14446-rule.php'); - - $analyser = self::getContainer()->getByType(Analyser::class); - $finalizer = self::getContainer()->getByType(AnalyserResultFinalizer::class); - $errors = $finalizer->finalize( - $analyser->analyse([$file], null, null, true), - false, - true, - )->getErrors(); - - $this->assertNoErrors($errors); - } - } diff --git a/tests/PHPStan/Analyser/data/bug-14446-rule.php b/tests/PHPStan/Analyser/data/bug-14446-rule.php deleted file mode 100644 index 5fd2df5f4a2..00000000000 --- a/tests/PHPStan/Analyser/data/bug-14446-rule.php +++ /dev/null @@ -1,57 +0,0 @@ - 0 ? [1] : []; - foreach ($items as $item) { - $current = false; - } - } - - var_dump($initial === true); -} - -/** - * @param mixed $value - */ -function testForeachKeyOverwrite($value): void { - if (is_array($value) && $value !== []) { - $hasOnlyStringKey = true; - foreach (array_keys($value) as $key) { - if (is_int($key)) { - $hasOnlyStringKey = false; - break; - } - } - - if ($hasOnlyStringKey) { - foreach ($value as $key => $element) { - } - } - } -} From 5c33ca2d8ee2bbed81e1f8f1f3a7c64a61a31d44 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 08:24:24 +0000 Subject: [PATCH 08/10] Deduplicate bug-14446 test data file Use a single shared test data file (tests/PHPStan/Analyser/data/bug-14446.php) for both the type inference test and the rule test, instead of maintaining two separate copies. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/data/bug-14446.php | 2 + ...rictComparisonOfDifferentTypesRuleTest.php | 2 +- .../Rules/Comparison/data/bug-14446.php | 37 ------------------- 3 files changed, 3 insertions(+), 38 deletions(-) delete mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14446.php diff --git a/tests/PHPStan/Analyser/data/bug-14446.php b/tests/PHPStan/Analyser/data/bug-14446.php index 2096c3051fb..da6f9daa262 100644 --- a/tests/PHPStan/Analyser/data/bug-14446.php +++ b/tests/PHPStan/Analyser/data/bug-14446.php @@ -21,6 +21,7 @@ function test(bool $initial): void { } assertType('bool', $initial); + var_dump($initial === true); } function testMaybeIterable(bool $initial): void { @@ -40,6 +41,7 @@ function testMaybeIterable(bool $initial): void { } assertType('bool', $initial); + var_dump($initial === true); } /** diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 3cf6ab40ea6..3c928fefd1c 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1194,7 +1194,7 @@ public function testBug13421(): void public function testBug14446(): void { $this->polluteScopeWithAlwaysIterableForeach = false; - $this->analyse([__DIR__ . '/data/bug-14446.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/data/bug-14446.php'], []); } } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14446.php b/tests/PHPStan/Rules/Comparison/data/bug-14446.php deleted file mode 100644 index 17ee20a8458..00000000000 --- a/tests/PHPStan/Rules/Comparison/data/bug-14446.php +++ /dev/null @@ -1,37 +0,0 @@ - 0 ? [1] : []; - foreach ($items as $item) { - $current = false; - } - } - - var_dump($initial === true); -} From d8f21c41cd516c10a838f8bb7cdff5116ad79a8e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 10:18:15 +0000 Subject: [PATCH 09/10] Apply same intersectConditionalExpressions fix to processFinallyScope processFinallyScope had the same issue as processAlwaysIterableForeachScopeWithoutPollute: it preserved stale conditional expressions from $this (the try/catch branch scope) even when the finally block reassigned variables that invalidated those conditional expressions. For example, $current = $initial followed by $current = false in a finally block would incorrectly narrow $initial to false inside if (!$current), because the alias relationship was not properly invalidated. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 +- tests/PHPStan/Analyser/data/bug-14446.php | 32 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 75dbfa339be..5fcd2d2318e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3732,7 +3732,7 @@ public function processFinallyScope(self $finallyScope, self $originalFinallySco $finallyScope->nativeExpressionTypes, $originalFinallyScope->nativeExpressionTypes, ), - $this->conditionalExpressions, + $this->intersectConditionalExpressions($finallyScope->conditionalExpressions), $this->inClosureBindScopeClasses, $this->anonymousFunctionReflection, $this->inFirstLevelStatement, diff --git a/tests/PHPStan/Analyser/data/bug-14446.php b/tests/PHPStan/Analyser/data/bug-14446.php index da6f9daa262..03d99593edc 100644 --- a/tests/PHPStan/Analyser/data/bug-14446.php +++ b/tests/PHPStan/Analyser/data/bug-14446.php @@ -44,6 +44,38 @@ function testMaybeIterable(bool $initial): void { var_dump($initial === true); } +function testFinally(bool $initial): void { + $current = $initial; + try { + // nothing + } finally { + $current = false; + } + assertType('false', $current); + assertType('bool', $initial); + if (!$current) { + assertType('bool', $initial); + } +} + +function testFinallyWithCatch(bool $initial): void { + $current = $initial; + try { + doSomething(); + } catch (\Exception $e) { + // nothing + } finally { + $current = false; + } + assertType('false', $current); + if (!$current) { + assertType('bool', $initial); + } + assertType('bool', $initial); +} + +function doSomething(): void {} + /** * @param mixed $value */ From f6abe3c6424f3bdb5f01e126e31acdd56b0b34f8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 11:58:44 +0000 Subject: [PATCH 10/10] Remove OverwriteVariablesWithForeachRule from bug-14446 test config The OverwriteVariablesWithForeachRule belongs to phpstan-strict-rules and should be tested in that repo. The type inference test only needs the polluteScopeWithAlwaysIterableForeach: false parameter. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/bug-14446.neon | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/PHPStan/Analyser/bug-14446.neon b/tests/PHPStan/Analyser/bug-14446.neon index 58913854694..3ee516d3be6 100644 --- a/tests/PHPStan/Analyser/bug-14446.neon +++ b/tests/PHPStan/Analyser/bug-14446.neon @@ -1,8 +1,2 @@ parameters: polluteScopeWithAlwaysIterableForeach: false - -services: - - - class: PHPStan\Rules\ForeachLoop\OverwriteVariablesWithForeachRule - tags: - - phpstan.rules.rule