From 9c07b7e61edde81f22a9ef4f792d6f2daa3659d3 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 1 Jul 2026 23:51:54 +0200 Subject: [PATCH 1/2] [CodeQuality] Add ChangeMockObjectReturnUnionToIntersectionRector --- config/sets/phpunit-code-quality.php | 2 + ...ectReturnUnionToIntersectionRectorTest.php | 28 ++++ .../Fixture/fixture.php.inc | 37 +++++ .../Fixture/short_mock_object.php.inc | 39 ++++++ .../Fixture/skip_non_mock_union.php.inc | 16 +++ .../Fixture/skip_non_test_class.php.inc | 14 ++ .../config/configured_rule.php | 9 ++ ...kObjectReturnUnionToIntersectionRector.php | 130 ++++++++++++++++++ 8 files changed, 275 insertions(+) create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/ChangeMockObjectReturnUnionToIntersectionRectorTest.php create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/fixture.php.inc create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/short_mock_object.php.inc create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_mock_union.php.inc create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_test_class.php.inc create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/config/configured_rule.php create mode 100644 rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php diff --git a/config/sets/phpunit-code-quality.php b/config/sets/phpunit-code-quality.php index 10aba745e..449d3fe66 100644 --- a/config/sets/phpunit-code-quality.php +++ b/config/sets/phpunit-code-quality.php @@ -19,6 +19,7 @@ use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\AddInstanceofAssertForNullableArgumentRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\AddInstanceofAssertForNullableInstanceRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\BareCreateMockAssignToDirectUseRector; +use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\ChangeMockObjectReturnUnionToIntersectionRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\DataProviderArrayItemsNewLinedRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\EntityDocumentCreateMockToDirectNewRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\NoSetupWithParentCallOverrideRector; @@ -155,6 +156,7 @@ EntityDocumentCreateMockToDirectNewRector::class, ReplaceAtMethodWithDesiredMatcherRector::class, BareCreateMockAssignToDirectUseRector::class, + ChangeMockObjectReturnUnionToIntersectionRector::class, DecorateWillReturnMapWithExpectsMockRector::class, // dead code diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/ChangeMockObjectReturnUnionToIntersectionRectorTest.php b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/ChangeMockObjectReturnUnionToIntersectionRectorTest.php new file mode 100644 index 000000000..2e31915bd --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/ChangeMockObjectReturnUnionToIntersectionRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/fixture.php.inc b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/fixture.php.inc new file mode 100644 index 000000000..81e4a5a5c --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/fixture.php.inc @@ -0,0 +1,37 @@ +createMock(Event::class); + } +} + +?> +----- +createMock(Event::class); + } +} + +?> diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/short_mock_object.php.inc b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/short_mock_object.php.inc new file mode 100644 index 000000000..401b5f247 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/short_mock_object.php.inc @@ -0,0 +1,39 @@ +createMock(Event::class); + } +} + +?> +----- +createMock(Event::class); + } +} + +?> diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_mock_union.php.inc b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_mock_union.php.inc new file mode 100644 index 000000000..7d69bb692 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_mock_union.php.inc @@ -0,0 +1,16 @@ +createEvent(); + } +} diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_test_class.php.inc b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_test_class.php.inc new file mode 100644 index 000000000..0369b0b3c --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/skip_non_test_class.php.inc @@ -0,0 +1,14 @@ +createMock(Event::class); + } +} diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/config/configured_rule.php b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/config/configured_rule.php new file mode 100644 index 000000000..ba98df130 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/config/configured_rule.php @@ -0,0 +1,9 @@ +withRules([ChangeMockObjectReturnUnionToIntersectionRector::class]); diff --git a/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php b/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php new file mode 100644 index 000000000..5df142703 --- /dev/null +++ b/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php @@ -0,0 +1,130 @@ +testsNodeAnalyzer->isInTestClass($node)) { + return null; + } + + $phpDocInfo = $this->phpDocInfoFactory->createFromNode($node); + if (! $phpDocInfo instanceof PhpDocInfo) { + return null; + } + + $returnTagValueNode = $phpDocInfo->getReturnTagValue(); + if (! $returnTagValueNode instanceof ReturnTagValueNode) { + return null; + } + + $returnTypeNode = $returnTagValueNode->type; + if (! $returnTypeNode instanceof UnionTypeNode) { + return null; + } + + // must contain a MockObject member to be a mock union + if (! $this->hasMockObjectType($returnTypeNode)) { + return null; + } + + $bracketsAwareIntersectionTypeNode = new BracketsAwareIntersectionTypeNode($returnTypeNode->types); + $this->phpDocTypeChanger->changeReturnTypeNode($node, $phpDocInfo, $bracketsAwareIntersectionTypeNode); + + return $node; + } + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Change a MockObject @return union docblock to an intersection type', + [ + new CodeSample( + <<<'CODE_SAMPLE' +use PHPUnit\Framework\TestCase; + +final class SomeTest extends TestCase +{ + /** + * @return Event|\PHPUnit\Framework\MockObject\MockObject + */ + private function createEvent(): \PHPUnit\Framework\MockObject\MockObject + { + return $this->createMock(Event::class); + } +} +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +use PHPUnit\Framework\TestCase; + +final class SomeTest extends TestCase +{ + /** + * @return Event&\PHPUnit\Framework\MockObject\MockObject + */ + private function createEvent(): \PHPUnit\Framework\MockObject\MockObject + { + return $this->createMock(Event::class); + } +} +CODE_SAMPLE + ), + ] + ); + } + + private function hasMockObjectType(UnionTypeNode $unionTypeNode): bool + { + return array_any($unionTypeNode->types, fn(TypeNode $typeNode): bool => $this->isMockObjectType($typeNode)); + } + + private function isMockObjectType(TypeNode $typeNode): bool + { + if (! $typeNode instanceof IdentifierTypeNode) { + return false; + } + + $typeName = ltrim($typeNode->name, '\\'); + + return $typeName === PHPUnitClassName::MOCK_OBJECT || $typeName === 'MockObject'; + } +} From 6fabbf88643d9091853eac6f0c79d7640ed2e86b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 1 Jul 2026 23:55:26 +0200 Subject: [PATCH 2/2] extend rule to Stub return union --- .../Fixture/stub.php.inc | 39 +++++++++++++++++++ ...kObjectReturnUnionToIntersectionRector.php | 18 ++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/stub.php.inc diff --git a/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/stub.php.inc b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/stub.php.inc new file mode 100644 index 000000000..6afa3b37b --- /dev/null +++ b/rules-tests/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector/Fixture/stub.php.inc @@ -0,0 +1,39 @@ +createStub(Event::class); + } +} + +?> +----- +createStub(Event::class); + } +} + +?> diff --git a/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php b/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php index 5df142703..fba4ac703 100644 --- a/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php +++ b/rules/CodeQuality/Rector/ClassMethod/ChangeMockObjectReturnUnionToIntersectionRector.php @@ -61,8 +61,8 @@ public function refactor(Node $node): ?ClassMethod return null; } - // must contain a MockObject member to be a mock union - if (! $this->hasMockObjectType($returnTypeNode)) { + // must contain a MockObject or Stub member to be a mock union + if (! $this->hasMockObjectOrStubType($returnTypeNode)) { return null; } @@ -112,12 +112,14 @@ private function createEvent(): \PHPUnit\Framework\MockObject\MockObject ); } - private function hasMockObjectType(UnionTypeNode $unionTypeNode): bool + private function hasMockObjectOrStubType(UnionTypeNode $unionTypeNode): bool { - return array_any($unionTypeNode->types, fn(TypeNode $typeNode): bool => $this->isMockObjectType($typeNode)); + return array_any($unionTypeNode->types, fn (TypeNode $typeNode): bool => $this->isMockObjectOrStubType( + $typeNode + )); } - private function isMockObjectType(TypeNode $typeNode): bool + private function isMockObjectOrStubType(TypeNode $typeNode): bool { if (! $typeNode instanceof IdentifierTypeNode) { return false; @@ -125,6 +127,10 @@ private function isMockObjectType(TypeNode $typeNode): bool $typeName = ltrim($typeNode->name, '\\'); - return $typeName === PHPUnitClassName::MOCK_OBJECT || $typeName === 'MockObject'; + return in_array( + $typeName, + [PHPUnitClassName::MOCK_OBJECT, 'MockObject', PHPUnitClassName::STUB, 'Stub'], + true + ); } }