From 19e694ddaca6970dea629a087aa6c80287bdee96 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 10:58:48 +0200 Subject: [PATCH 1/4] Remove a sealed type from itself in a union removal ObjectType::tryRemove() subtracts the union members a sealed hierarchy holds in one go since fd5580d63. It skipped the check that TypeCombinator::remove() does for each member: a member that is a supertype of the whole type removes everything. Removing `SealedBase|Other` from `SealedBase` then gave `SealedBase~SealedBase` instead of never, so a multi-catch `catch (SealedBase | Other $e)` let the throw point of a `@throws SealedBase` call escape the try block. --- src/Type/ObjectType.php | 4 + ...eckedExceptionInFunctionThrowsRuleTest.php | 11 +++ ...ion-function-throws-sealed-multi-catch.php | 87 +++++++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 27 ++++++ .../Type/data/sealed-hierarchy-remove.php | 24 +++++ turbo-ext/src/ObjectType.cpp | 5 ++ 6 files changed, 158 insertions(+) create mode 100644 tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php create mode 100644 tests/PHPStan/Type/data/sealed-hierarchy-remove.php diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 16a2ed918c4..d52efe09254 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -2094,6 +2094,10 @@ public function tryRemove(Type $typeToRemove): ?Type // time, without rebuilding the subtracted union once per member. $membersToRemove = []; foreach ($typeToRemove->getTypes() as $member) { + if ($member->isSuperTypeOf($this)->yes()) { + return new NeverType(); + } + $isSuperTypeOfMember = $this->isSuperTypeOf($member); if ($isSuperTypeOfMember->yes()) { $membersToRemove[] = $member; diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php index 08f06c6f97c..d9671679344 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php @@ -83,4 +83,15 @@ public function testConditionalThrows(): void ]); } + public function testSealedExceptionInMultiCatch(): void + { + require_once __DIR__ . '/data/missing-exception-function-throws-sealed-multi-catch.php'; + $this->analyse([__DIR__ . '/data/missing-exception-function-throws-sealed-multi-catch.php'], [ + [ + 'Function MissingExceptionFunctionThrowsSealedMultiCatch\\multiCatchLeaksOtherSubtype() throws checked exception MissingExceptionFunctionThrowsSealedMultiCatch\\SubB but it\'s missing from the PHPDoc @throws tag.', + 84, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php b/tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php new file mode 100644 index 00000000000..990e01d4cf3 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php @@ -0,0 +1,87 @@ + Date: Tue, 6 Oct 2026 10:58:51 +0200 Subject: [PATCH 2/4] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 88c83cac563..27af7bea08c 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '28c29b8'; + public const EXPECTED_EXTENSION_VERSION = '19e694d'; private static bool $active = false; From b293e62fbf8e2d94ce908b687d26f759c5488706 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 12:04:04 +0200 Subject: [PATCH 3/4] Cover a sealed type minus a union in the turbo type-family test The differential test compares ObjectType::tryRemove() of the native class and the PHP twin when a union member is a supertype of the sealed type. With the native ObjectType of 2.3.x the native result differs from the PHP result. --- turbo-ext/tests/type-family.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/turbo-ext/tests/type-family.php b/turbo-ext/tests/type-family.php index b3c2d16ab39..9dd98d4f2be 100644 --- a/turbo-ext/tests/type-family.php +++ b/turbo-ext/tests/type-family.php @@ -2492,6 +2492,19 @@ public function canAccessConstant(\PHPStan\Reflection\ClassConstantReflection $c $r["enum minus cases $caseName"] = $view($remaining); } $r['equal union classes'] = [$view($subjects['throwable']->tryRemove(new $objectClass(\Error::class))), $view($subjects['dateTimeInterface']->tryRemove(new $objectClass(\DateTime::class))), $view($subjects['dateTimeInterface']->tryRemove(new $objectClass(\DateTimeImmutable::class)))]; + // a sealed type minus a union: a member that is a supertype of the whole + // type removes everything, whatever the other members are + $sealedUnion = static fn (\PHPStan\Type\Type ...$members): \PHPStan\Type\Type => new \PHPStan\Type\UnionType($members); + $r['sealed tryRemove union'] = [ + $view($subjects['enum']->tryRemove($sealedUnion(new $objectClass($objectEnum), $others['stdClass']))), + $view($subjects['enum']->tryRemove($sealedUnion($others['stdClass'], new $objectClass($objectEnum)))), + $view($subjects['enum']->tryRemove($sealedUnion(new $objectClass(\UnitEnum::class), $others['exception']))), + $view($subjects['enum']->tryRemove($sealedUnion($others['caseFoo'], new $objectClass(\UnitEnum::class)))), + $view($subjects['enum']->tryRemove($sealedUnion($others['caseFoo'], $others['stdClass']))), + $view($subjects['enumMinusFoo']->tryRemove($sealedUnion(new $objectClass($objectEnum), $others['stdClass']))), + $view($subjects['enumWithReflection']->tryRemove($sealedUnion(new $objectClass($objectEnum), $others['stdClass']))), + $view(\PHPStan\Type\TypeCombinator::remove($subjects['enum'], $sealedUnion(new $objectClass($objectEnum), $others['stdClass']))), + ]; $r['object equals object'] = [(new $objectClass(\stdClass::class))->equals(new $objectClass(\stdClass::class)), (new $objectClass(\stdClass::class))->equals(new $objectClass('stdclass')), (new $objectClass(\stdClass::class))->equals(new $genericClass(\stdClass::class, [])), (new $genericClass(\stdClass::class, []))->equals(new $objectClass(\stdClass::class)), (new $caseClass($objectEnum, 'FOO'))->equals(new $caseClass($objectEnum, 'FOO')), (new $caseClass($objectEnum, 'FOO'))->equals(new $objectClass($objectEnum))]; // the constructors by named arguments $r['object named'] = $view(new $objectClass(className: \stdClass::class, classReflection: null)); From 7d260e05190ad777f98f04ae0753f48976731091 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 12:04:23 +0200 Subject: [PATCH 4/4] Rename regression test to bug-15392 The fixture of TypeCombinatorTest uses the namespace Bug15392Types because the rule test fixture declares classes with the same names in Bug15392. Fixes https://github.com/phpstan/phpstan/issues/15392 --- ...ingCheckedExceptionInFunctionThrowsRuleTest.php | 8 ++++---- ...throws-sealed-multi-catch.php => bug-15392.php} | 2 +- tests/PHPStan/Type/TypeCombinatorTest.php | 14 +++++++------- .../{sealed-hierarchy-remove.php => bug-15392.php} | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) rename tests/PHPStan/Rules/Exceptions/data/{missing-exception-function-throws-sealed-multi-catch.php => bug-15392.php} (95%) rename tests/PHPStan/Type/data/{sealed-hierarchy-remove.php => bug-15392.php} (84%) diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php index d9671679344..6511c2bbc41 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php @@ -83,12 +83,12 @@ public function testConditionalThrows(): void ]); } - public function testSealedExceptionInMultiCatch(): void + public function testBug15392(): void { - require_once __DIR__ . '/data/missing-exception-function-throws-sealed-multi-catch.php'; - $this->analyse([__DIR__ . '/data/missing-exception-function-throws-sealed-multi-catch.php'], [ + require_once __DIR__ . '/data/bug-15392.php'; + $this->analyse([__DIR__ . '/data/bug-15392.php'], [ [ - 'Function MissingExceptionFunctionThrowsSealedMultiCatch\\multiCatchLeaksOtherSubtype() throws checked exception MissingExceptionFunctionThrowsSealedMultiCatch\\SubB but it\'s missing from the PHPDoc @throws tag.', + 'Function Bug15392\\multiCatchLeaksOtherSubtype() throws checked exception Bug15392\\SubB but it\'s missing from the PHPDoc @throws tag.', 84, ], ]); diff --git a/tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php b/tests/PHPStan/Rules/Exceptions/data/bug-15392.php similarity index 95% rename from tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php rename to tests/PHPStan/Rules/Exceptions/data/bug-15392.php index 990e01d4cf3..797d0424b32 100644 --- a/tests/PHPStan/Rules/Exceptions/data/missing-exception-function-throws-sealed-multi-catch.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-15392.php @@ -1,6 +1,6 @@