From c7047997008e80cbe2008455268b0d36c57d6ca8 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:16:14 +0000 Subject: [PATCH] Require the union with the other branch's type to equal the consequent before rescuing a one-sided conditional holder - MutatingScope::preserveVacuousConditionalExpressions() kept a conditional holder present in only one branch when the other branch's state was a subtype of the holder's consequent. Without bleeding edge, array shapes are unsealed in isSuperTypeOf(), so array{}|array{a?: T} counted as a supertype of array{a?: T, b: T}. Applying that holder later dropped the 'b' key, which caused false positives such as "Offset ... on left side of ?? does not exist". - Now also require TypeCombinator::union(consequent, other) to equal the consequent, so the other branch's state adds nothing to it. - Ported the same check to turbo-ext/src/MutatingScope.cpp. - Probed the other isSuperTypeOf() uses in the conditional-holder machinery (vacuous-guard check, ScopeOps guard matching, StatementsHandler loop holders). They compare guards rather than check that a consequent still holds, so they don't have this problem. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/MutatingScope.php | 8 +++ tests/PHPStan/Analyser/Bug15534Test.php | 29 ++++++++ tests/PHPStan/Analyser/data/bug-15534.php | 38 +++++++++++ .../NullCoalesceRuleNoBleedingEdgeTest.php | 44 +++++++++++++ .../Rules/Variables/data/bug-15534.php | 66 +++++++++++++++++++ turbo-ext/src/MutatingScope.cpp | 8 +++ 6 files changed, 193 insertions(+) create mode 100644 tests/PHPStan/Analyser/Bug15534Test.php create mode 100644 tests/PHPStan/Analyser/data/bug-15534.php create mode 100644 tests/PHPStan/Rules/Variables/NullCoalesceRuleNoBleedingEdgeTest.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-15534.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 85867200a20..667e388a0ac 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4557,6 +4557,14 @@ private function preserveVacuousConditionalExpressions( continue; } + // Array shapes are unsealed in isSuperTypeOf(): array{a?: int} is a supertype + // of array{a?: int, b: int}, yet applying the holder would drop the 'b' key + // the other branch tracks. The consequent only holds on both paths when the + // other branch's state adds nothing to it. + if (!TypeCombinator::union($typeHolder->getType(), $otherTargetHolder->getType())->equals($typeHolder->getType())) { + continue; + } + $currentConditionalExpressions[$exprString][$key] = $holder; } } diff --git a/tests/PHPStan/Analyser/Bug15534Test.php b/tests/PHPStan/Analyser/Bug15534Test.php new file mode 100644 index 00000000000..f0314f12fb8 --- /dev/null +++ b/tests/PHPStan/Analyser/Bug15534Test.php @@ -0,0 +1,29 @@ +assertFileAsserts($assertType, $file, ...$args); + } + +} diff --git a/tests/PHPStan/Analyser/data/bug-15534.php b/tests/PHPStan/Analyser/data/bug-15534.php new file mode 100644 index 00000000000..44b780ab1b4 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-15534.php @@ -0,0 +1,38 @@ + + */ +class NullCoalesceRuleNoBleedingEdgeTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NullCoalesceRule( + new IssetCheck( + new PropertyDescriptor(), + true, + $this->shouldTreatPhpDocTypesAsCertain(), + ), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + true, + ); + } + + #[Override] + public static function getAdditionalConfigFiles(): array + { + return []; + } + + public function testBug15534(): void + { + $this->analyse([__DIR__ . '/data/bug-15534.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-15534.php b/tests/PHPStan/Rules/Variables/data/bug-15534.php new file mode 100644 index 00000000000..4c2aca54d6d --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-15534.php @@ -0,0 +1,66 @@ +