From eda18f6047a0148f05cc781a54e88a60a6b833fe Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:30:35 +0000 Subject: [PATCH] Return `never` from sealed `ObjectType::tryRemove()` when a removed union member is a supertype, and from `TemplateType::tryRemove()` when the bound is emptied - ObjectType::tryRemove(): the sealed-hierarchy branch that removes a whole union at once only collected members `$this` is a supertype of and subtracted them, so removing `BaseEx|OtherEx` from a sealed `BaseEx` produced `BaseEx~BaseEx` instead of `never` (unlike `TypeCombinator::remove()` with a single type). A multi-catch `catch (BaseEx | OtherEx $e)` therefore did not catch a `BaseEx` throw point. Return `never` when a member is a supertype of `$this`. Enums take the same path (`SomeEnum|stdClass`). - TemplateTypeTrait::tryRemove(): removing the same union from `T of SomeEnum` now empties the bound, and rebuilding the template with a `never` bound turned it into `T of mixed`. Return `never` instead: T is a subset of its bound. - Both changes are mirrored in the turbo extension (turbo-ext/src/ObjectType.cpp, turbo-ext/src/TypeTraits.cpp). - Probed and found correct: union `fromType`s (`SomeEnum|int`), `StaticType`, a sealed type with a subtracted case, multi-catch in methods, catching a parent class or every allowed subtype, and narrowing with `instanceof ... || instanceof ...`, which removes members one at a time. Co-Authored-By: Claude Opus 5.5 --- src/Type/Generic/TemplateTypeTrait.php | 3 ++ src/Type/ObjectType.php | 3 ++ ...FunctionWithExplicitThrowPointRuleTest.php | 7 ++++ .../Rules/Exceptions/data/bug-15392.php | 33 +++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 41 +++++++++++++++++++ turbo-ext/src/ObjectType.cpp | 8 ++++ turbo-ext/src/TypeTraits.cpp | 2 + 7 files changed, 97 insertions(+) create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-15392.php diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index d5d15a26abf..4bb15474666 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -409,6 +409,9 @@ public function tryRemove(Type $typeToRemove): ?Type if ($this->getBound() === $bound || $this->getBound()->equals($bound)) { return null; } + if ($bound instanceof NeverType) { + return $bound; + } return TemplateTypeFactory::create( $this->getScope(), diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 139315d1c4c..15a60c8c223 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -2094,6 +2094,9 @@ 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/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php index 207b05bb76f..bc6191d89ad 100644 --- a/tests/PHPStan/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php @@ -104,4 +104,11 @@ public function testBug13515(): void $this->analyse([__DIR__ . '/data/bug-13515.php'], []); } + public function testBug15392(): void + { + $this->missingCheckedExceptionInThrows = false; + $this->checkedExceptionClasses = []; + $this->analyse([__DIR__ . '/data/bug-15392.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-15392.php b/tests/PHPStan/Rules/Exceptions/data/bug-15392.php new file mode 100644 index 00000000000..c649a2294a0 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-15392.php @@ -0,0 +1,33 @@ +isSuperTypeOf($this)->yes()) return new NeverType(); */ + zval selfZv; + ZVAL_OBJ(&selfZv, self); + zv::Val memberIsSuperType = pt_type_call(Z_OBJ_P(member), PT_LC("issupertypeof"), 1, &selfZv); + if (UNEXPECTED(memberIsSuperType.isUndef())) return zv::Val(); + zend_long memberIsSuperTypeValue = pt_type_result_trinary(memberIsSuperType.raw()); + if (UNEXPECTED(memberIsSuperTypeValue < 0)) return zv::Val(); + if (memberIsSuperTypeValue == PT_TRI_YES) return pt_type_new_never_type(); zv::Val isSuperTypeOfMember = thisCall(PT_LC("issupertypeof"), otIsSuperTypeOf, 1, member, [&]() { return isSuperTypeOf(member); }); if (UNEXPECTED(isSuperTypeOfMember.isUndef())) return zv::Val(); zend_long memberValue = pt_type_result_trinary(isSuperTypeOfMember.raw()); diff --git a/turbo-ext/src/TypeTraits.cpp b/turbo-ext/src/TypeTraits.cpp index 6272c721393..a3d8e6e77b3 100644 --- a/turbo-ext/src/TypeTraits.cpp +++ b/turbo-ext/src/TypeTraits.cpp @@ -4705,6 +4705,8 @@ class TemplateTypeTrait zv::Val equal = pt_type_op(Z_OBJ_P(boundOnceMore.raw()), PT_OP_EQUALS, 1, removed.raw()); if (UNEXPECTED(equal.isUndef())) return zv::Val(); if (zend_is_true(equal.raw())) return zv::Val::null(); + /* if ($bound instanceof NeverType) return $bound; */ + if (zv::Ref(removed.raw()).instanceOf(pt_ce_never_type)) return removed; return recreateKeepingDefault(removed.raw()); }