From e11827f60b44529ba4aec56e192f42edde7c0853 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 14:06:52 +0200 Subject: [PATCH 01/13] AssertEmptyIsDiscouragedRule is auto-fixable --- .../PHPUnit/AssertEmptyIsDiscouragedRule.php | 58 ++++++++++++++++++- .../AssertEmptyIsDiscouragedRuleTest.php | 5 ++ .../assert-empty-is-discouraged-fixable.php | 49 ++++++++++++++++ ...ert-empty-is-discouraged-fixable.php.fixed | 49 ++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php create mode 100644 tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index 5459e263..d1bc3b21 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -7,9 +7,15 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; +use PhpParser\Node\Scalar\Int_; +use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; +use function array_merge; use function count; use function in_array; use function sprintf; @@ -43,11 +49,57 @@ public function processNode(Node $node, Scope $scope): array return []; } + $errorBuilder = RuleErrorBuilder::message(sprintf('%s() is not allowed. Use more strict assertion.', $node->name->toString())) + ->identifier('phpunit.assertEmpty'); + + $replacement = $this->getReplacement($scope->getType($node->getArgs()[0]->value), $node->name->toLowerString() === 'assertnotempty'); + if ($replacement !== null) { + [$correctName, $expectedValue] = $replacement; + $errorBuilder->fixNode($node, static function (CallLike $node) use ($correctName, $expectedValue) { + $node->name = new Identifier($correctName); + if ($expectedValue !== null) { + $node->args = array_merge([new Node\Arg($expectedValue)], $node->args); + } + + return $node; + }); + } + return [ - RuleErrorBuilder::message(sprintf('%s() is not allowed. Use more strict assertion.', $node->name->toString())) - ->identifier('phpunit.assertEmpty') - ->build(), + $errorBuilder->build(), ]; } + /** + * @return array{string, Node\Expr|null}|null + */ + private function getReplacement(Type $type, bool $negated): ?array + { + if ($type instanceof UnionType) { + if (TypeCombinator::containsNull($type) && TypeCombinator::removeNull($type)->isObject()->yes()) { + return [$negated ? 'assertNotNull' : 'assertNull', null]; + } + + return null; + } + + if ($type->isBoolean()->yes()) { + return [$negated ? 'assertTrue' : 'assertFalse', null]; + } + if ($type->isArray()->yes()) { + return [$negated ? 'assertNotCount' : 'assertCount', new Int_(0)]; + } + if ($type->isInteger()->yes()) { + return [$negated ? 'assertNotSame' : 'assertSame', new Int_(0)]; + } + if ($type->isNull()->yes()) { + return [$negated ? 'assertNotNull' : 'assertNull', null]; + } + if ($type->isNonFalsyString()->yes()) { + return [$negated ? 'assertNotSame' : 'assertSame', new String_('')]; + } + + return null; + } + } diff --git a/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php b/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php index 156a6e8b..cb378f3d 100644 --- a/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php +++ b/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php @@ -21,6 +21,11 @@ public function testRule(): void ]); } + public function testFix(): void + { + $this->fix(__DIR__ . '/data/assert-empty-is-discouraged-fixable.php', __DIR__ . '/data/assert-empty-is-discouraged-fixable.php.fixed'); + } + protected function getRule(): Rule { return new AssertEmptyIsDiscouragedRule(); diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php new file mode 100644 index 00000000..8be47ffb --- /dev/null +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -0,0 +1,49 @@ +assertEmpty($boolean); + $this->assertNotEmpty($boolean); + $this->assertEmpty($array, 'message'); + $this->assertNotEmpty($array); + $this->assertEmpty($integer); + $this->assertNotEmpty($integer); + Assert::assertEmpty($float); + Assert::assertNotEmpty($float); + static::assertEmpty(null); + static::assertNotEmpty(null); + $this->assertEmpty($nonFalsyString); + $this->assertNotEmpty($otherNonFalsyString); + $this->assertEmpty($string); + $this->assertNotEmpty($string); + $this->assertEmpty($union); + $this->assertNotEmpty($union); + $this->assertEmpty($nullableObject); + $this->assertNotEmpty($otherNullableObject); + } + +} diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed new file mode 100644 index 00000000..2bc75f44 --- /dev/null +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -0,0 +1,49 @@ +assertFalse($boolean); + $this->assertTrue($boolean); + $this->assertCount(0, $array, 'message'); + $this->assertNotCount(0, $array); + $this->assertSame(0, $integer); + $this->assertNotSame(0, $integer); + Assert::assertEmpty($float); + Assert::assertNotEmpty($float); + static::assertNull(null); + static::assertNotNull(null); + $this->assertSame('', $nonFalsyString); + $this->assertNotSame('', $otherNonFalsyString); + $this->assertEmpty($string); + $this->assertNotEmpty($string); + $this->assertEmpty($union); + $this->assertNotEmpty($union); + $this->assertNull($nullableObject); + $this->assertNotNull($otherNullableObject); + } + +} From 9d48b014cfbf7188dd2d1f920252859e96850724 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 14:07:44 +0200 Subject: [PATCH 02/13] native unions --- .../PHPUnit/AssertEmptyIsDiscouragedRuleTest.php | 10 ++++++++++ .../assert-empty-is-discouraged-native-union.php | 16 ++++++++++++++++ ...t-empty-is-discouraged-native-union.php.fixed | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php create mode 100644 tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed diff --git a/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php b/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php index cb378f3d..bb4b90ad 100644 --- a/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php +++ b/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -26,6 +27,15 @@ public function testFix(): void $this->fix(__DIR__ . '/data/assert-empty-is-discouraged-fixable.php', __DIR__ . '/data/assert-empty-is-discouraged-fixable.php.fixed'); } + public function testNativeUnionTypeIsNotFixed(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Native union types require PHP 8.0.'); + } + + $this->fix(__DIR__ . '/data/assert-empty-is-discouraged-native-union.php', __DIR__ . '/data/assert-empty-is-discouraged-native-union.php.fixed'); + } + protected function getRule(): Rule { return new AssertEmptyIsDiscouragedRule(); diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php new file mode 100644 index 00000000..fdb62675 --- /dev/null +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php @@ -0,0 +1,16 @@ +assertEmpty($value); + $this->assertNotEmpty($otherValue); + } + +} diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed new file mode 100644 index 00000000..fdb62675 --- /dev/null +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed @@ -0,0 +1,16 @@ +assertEmpty($value); + $this->assertNotEmpty($otherValue); + } + +} From ecf9f21a624b77bfa8955026d9637cb7a7920363 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 14:13:02 +0200 Subject: [PATCH 03/13] fix php7 --- tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php | 2 +- .../PHPUnit/data/assert-empty-is-discouraged-native-union.php | 2 +- .../data/assert-empty-is-discouraged-native-union.php.fixed | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php b/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php index bb4b90ad..304d7c49 100644 --- a/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php +++ b/tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php @@ -30,7 +30,7 @@ public function testFix(): void public function testNativeUnionTypeIsNotFixed(): void { if (PHP_VERSION_ID < 80000) { - $this->markTestSkipped('Native union types require PHP 8.0.'); + self::markTestSkipped('Native union types require PHP 8.0.'); } $this->fix(__DIR__ . '/data/assert-empty-is-discouraged-native-union.php', __DIR__ . '/data/assert-empty-is-discouraged-native-union.php.fixed'); diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php index fdb62675..36a720e5 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php @@ -1,4 +1,4 @@ -= 8.0 namespace AssertEmptyIsDiscouragedNativeUnionTest; diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed index fdb62675..36a720e5 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed @@ -1,4 +1,4 @@ -= 8.0 namespace AssertEmptyIsDiscouragedNativeUnionTest; From 5c0ace907ee2a629642ff962703d93f218a884d0 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 14:15:10 +0200 Subject: [PATCH 04/13] remove artificial case of passing literal null to isNull --- src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php | 3 --- .../Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php | 2 -- .../PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed | 2 -- 3 files changed, 7 deletions(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index d1bc3b21..bbdc5094 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -92,9 +92,6 @@ private function getReplacement(Type $type, bool $negated): ?array if ($type->isInteger()->yes()) { return [$negated ? 'assertNotSame' : 'assertSame', new Int_(0)]; } - if ($type->isNull()->yes()) { - return [$negated ? 'assertNotNull' : 'assertNull', null]; - } if ($type->isNonFalsyString()->yes()) { return [$negated ? 'assertNotSame' : 'assertSame', new String_('')]; } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php index 8be47ffb..fe1fe682 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -34,8 +34,6 @@ public function test( $this->assertNotEmpty($integer); Assert::assertEmpty($float); Assert::assertNotEmpty($float); - static::assertEmpty(null); - static::assertNotEmpty(null); $this->assertEmpty($nonFalsyString); $this->assertNotEmpty($otherNonFalsyString); $this->assertEmpty($string); diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed index 2bc75f44..dea5e84a 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -34,8 +34,6 @@ final class AssertEmptyTest extends TestCase $this->assertNotSame(0, $integer); Assert::assertEmpty($float); Assert::assertNotEmpty($float); - static::assertNull(null); - static::assertNotNull(null); $this->assertSame('', $nonFalsyString); $this->assertNotSame('', $otherNonFalsyString); $this->assertEmpty($string); From 8d6f2236580e99b8b5ed6a24618eb8c7a45bea18 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 16:23:59 +0200 Subject: [PATCH 05/13] fix feedback --- .../PHPUnit/AssertEmptyIsDiscouragedRule.php | 16 +++++++++++-- .../assert-empty-is-discouraged-fixable.php | 18 +++++++++++++- ...ert-empty-is-discouraged-fixable.php.fixed | 24 +++++++++++++++---- ...sert-empty-is-discouraged-native-union.php | 11 ++++++++- ...mpty-is-discouraged-native-union.php.fixed | 11 ++++++++- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index bbdc5094..ea7b11c5 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; @@ -52,7 +53,13 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder = RuleErrorBuilder::message(sprintf('%s() is not allowed. Use more strict assertion.', $node->name->toString())) ->identifier('phpunit.assertEmpty'); - $replacement = $this->getReplacement($scope->getType($node->getArgs()[0]->value), $node->name->toLowerString() === 'assertnotempty'); + foreach ($node->getArgs() as $arg) { + if ($arg->name !== null) { + return [$errorBuilder->build()]; + } + } + + $replacement = $this->getReplacement($scope->getNativeType($node->getArgs()[0]->value), $node->name->toLowerString() === 'assertnotempty'); if ($replacement !== null) { [$correctName, $expectedValue] = $replacement; $errorBuilder->fixNode($node, static function (CallLike $node) use ($correctName, $expectedValue) { @@ -76,7 +83,12 @@ public function processNode(Node $node, Scope $scope): array private function getReplacement(Type $type, bool $negated): ?array { if ($type instanceof UnionType) { - if (TypeCombinator::containsNull($type) && TypeCombinator::removeNull($type)->isObject()->yes()) { + $typeWithoutNull = TypeCombinator::removeNull($type); + if ( + TypeCombinator::containsNull($type) + && $typeWithoutNull->isObject()->yes() + && (new ObjectType('SimpleXMLElement'))->isSuperTypeOf($typeWithoutNull)->no() + ) { return [$negated ? 'assertNotNull' : 'assertNull', null]; } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php index fe1fe682..4d9ec74f 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -11,6 +11,7 @@ final class AssertEmptyTest extends TestCase /** * @param non-falsy-string $nonFalsyString * @param non-falsy-string $otherNonFalsyString + * @param int $phpDocInteger * @param string|int $union */ public function test( @@ -23,7 +24,10 @@ public function test( string $nonFalsyString, string $otherNonFalsyString, ?\stdClass $nullableObject, - ?object $otherNullableObject + ?object $otherNullableObject, + ?\SimpleXMLElement $nullableSimpleXml, + $phpDocInteger, + $mixed ): void { $this->assertEmpty($boolean); @@ -42,6 +46,18 @@ public function test( $this->assertNotEmpty($union); $this->assertEmpty($nullableObject); $this->assertNotEmpty($otherNullableObject); + $this->assertEmpty($nullableSimpleXml); + $this->assertNotEmpty($phpDocInteger); + $this->assertEmpty($mixed); + } + + public function testNativeNonFalsyString(string $value): void + { + if ($value === '' || $value === '0') { + return; + } + + $this->assertNotEmpty($value); } } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed index dea5e84a..c035da6d 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -11,6 +11,7 @@ final class AssertEmptyTest extends TestCase /** * @param non-falsy-string $nonFalsyString * @param non-falsy-string $otherNonFalsyString + * @param int $phpDocInteger * @param string|int $union */ public function test( @@ -23,7 +24,10 @@ final class AssertEmptyTest extends TestCase string $nonFalsyString, string $otherNonFalsyString, ?\stdClass $nullableObject, - ?object $otherNullableObject + ?object $otherNullableObject, + ?\SimpleXMLElement $nullableSimpleXml, + $phpDocInteger, + $mixed ): void { $this->assertFalse($boolean); @@ -34,14 +38,26 @@ final class AssertEmptyTest extends TestCase $this->assertNotSame(0, $integer); Assert::assertEmpty($float); Assert::assertNotEmpty($float); - $this->assertSame('', $nonFalsyString); - $this->assertNotSame('', $otherNonFalsyString); + $this->assertEmpty($nonFalsyString); + $this->assertNotEmpty($otherNonFalsyString); $this->assertEmpty($string); $this->assertNotEmpty($string); $this->assertEmpty($union); $this->assertNotEmpty($union); $this->assertNull($nullableObject); - $this->assertNotNull($otherNullableObject); + $this->assertNotEmpty($otherNullableObject); + $this->assertEmpty($nullableSimpleXml); + $this->assertNotEmpty($phpDocInteger); + $this->assertEmpty($mixed); + } + + public function testNativeNonFalsyString(string $value): void + { + if ($value === '' || $value === '0') { + return; + } + + $this->assertNotSame('', $value); } } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php index 36a720e5..e287f749 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php @@ -7,10 +7,19 @@ final class AssertEmptyTest extends TestCase { - public function test(string|int $value, array|bool $otherValue): void + public function test( + string|int $value, + array|bool $otherValue, + \stdClass|int|null $nullableUnion, + bool $boolean, + int $integer + ): void { $this->assertEmpty($value); $this->assertNotEmpty($otherValue); + $this->assertEmpty($nullableUnion); + $this->assertEmpty(actual: $boolean); + $this->assertEmpty(message: 'message', actual: $integer); } } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed index 36a720e5..e287f749 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed @@ -7,10 +7,19 @@ use PHPUnit\Framework\TestCase; final class AssertEmptyTest extends TestCase { - public function test(string|int $value, array|bool $otherValue): void + public function test( + string|int $value, + array|bool $otherValue, + \stdClass|int|null $nullableUnion, + bool $boolean, + int $integer + ): void { $this->assertEmpty($value); $this->assertNotEmpty($otherValue); + $this->assertEmpty($nullableUnion); + $this->assertEmpty(actual: $boolean); + $this->assertEmpty(message: 'message', actual: $integer); } } From 93bd0ead206a037e69e7096e60ce1d0326b3f4a3 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 16:31:16 +0200 Subject: [PATCH 06/13] skip builtin classes --- .../PHPUnit/AssertEmptyIsDiscouragedRule.php | 18 ++++++++++++------ .../assert-empty-is-discouraged-fixable.php | 9 +++++++++ ...sert-empty-is-discouraged-fixable.php.fixed | 11 ++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index ea7b11c5..232ecaef 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -12,7 +12,6 @@ use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; @@ -84,11 +83,18 @@ private function getReplacement(Type $type, bool $negated): ?array { if ($type instanceof UnionType) { $typeWithoutNull = TypeCombinator::removeNull($type); - if ( - TypeCombinator::containsNull($type) - && $typeWithoutNull->isObject()->yes() - && (new ObjectType('SimpleXMLElement'))->isSuperTypeOf($typeWithoutNull)->no() - ) { + if (TypeCombinator::containsNull($type) && $typeWithoutNull->isObject()->yes()) { + $classReflections = $typeWithoutNull->getObjectClassReflections(); + if (count($classReflections) === 0) { + return null; + } + foreach ($classReflections as $classReflection) { + // some builtin classes like SimpleXmlElement have different empty() semantics + if ($classReflection->isBuiltin()) { + return null; + } + } + return [$negated ? 'assertNotNull' : 'assertNull', null]; } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php index 4d9ec74f..07eb77ce 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -5,6 +5,11 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; +final class UserDefinedObject +{ + +} + final class AssertEmptyTest extends TestCase { @@ -26,6 +31,8 @@ public function test( ?\stdClass $nullableObject, ?object $otherNullableObject, ?\SimpleXMLElement $nullableSimpleXml, + ?UserDefinedObject $nullableUserDefinedObject, + ?UserDefinedObject $otherNullableUserDefinedObject, $phpDocInteger, $mixed ): void @@ -47,6 +54,8 @@ public function test( $this->assertEmpty($nullableObject); $this->assertNotEmpty($otherNullableObject); $this->assertEmpty($nullableSimpleXml); + $this->assertEmpty($nullableUserDefinedObject); + $this->assertNotEmpty($otherNullableUserDefinedObject); $this->assertNotEmpty($phpDocInteger); $this->assertEmpty($mixed); } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed index c035da6d..abe72ce8 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -5,6 +5,11 @@ namespace AssertEmptyIsDiscouragedFixTest; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; +final class UserDefinedObject +{ + +} + final class AssertEmptyTest extends TestCase { @@ -26,6 +31,8 @@ final class AssertEmptyTest extends TestCase ?\stdClass $nullableObject, ?object $otherNullableObject, ?\SimpleXMLElement $nullableSimpleXml, + ?UserDefinedObject $nullableUserDefinedObject, + ?UserDefinedObject $otherNullableUserDefinedObject, $phpDocInteger, $mixed ): void @@ -44,9 +51,11 @@ final class AssertEmptyTest extends TestCase $this->assertNotEmpty($string); $this->assertEmpty($union); $this->assertNotEmpty($union); - $this->assertNull($nullableObject); + $this->assertEmpty($nullableObject); $this->assertNotEmpty($otherNullableObject); $this->assertEmpty($nullableSimpleXml); + $this->assertNull($nullableUserDefinedObject); + $this->assertNotNull($otherNullableUserDefinedObject); $this->assertNotEmpty($phpDocInteger); $this->assertEmpty($mixed); } From 9cef073105c0c8af1dc95deceb2342566ba18c45 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 16:41:52 +0200 Subject: [PATCH 07/13] kill mutant --- src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index 232ecaef..b19b9b85 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -83,7 +83,7 @@ private function getReplacement(Type $type, bool $negated): ?array { if ($type instanceof UnionType) { $typeWithoutNull = TypeCombinator::removeNull($type); - if (TypeCombinator::containsNull($type) && $typeWithoutNull->isObject()->yes()) { + if (TypeCombinator::containsNull($type)) { $classReflections = $typeWithoutNull->getObjectClassReflections(); if (count($classReflections) === 0) { return null; From 7b1c276cf3ec9b31f45f0ddc949453da039d7c7f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 5 Oct 2026 16:42:09 +0200 Subject: [PATCH 08/13] Update AssertEmptyIsDiscouragedRule.php --- src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index b19b9b85..ab2938f5 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -82,8 +82,9 @@ public function processNode(Node $node, Scope $scope): array private function getReplacement(Type $type, bool $negated): ?array { if ($type instanceof UnionType) { - $typeWithoutNull = TypeCombinator::removeNull($type); if (TypeCombinator::containsNull($type)) { + $typeWithoutNull = TypeCombinator::removeNull($type); + $classReflections = $typeWithoutNull->getObjectClassReflections(); if (count($classReflections) === 0) { return null; From 14325077077d118e386c3b047758576b04089c83 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 6 Oct 2026 08:15:34 +0200 Subject: [PATCH 09/13] skip unpack --- src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php | 2 +- .../Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php | 2 ++ .../PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index ab2938f5..a2aa0a61 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -53,7 +53,7 @@ public function processNode(Node $node, Scope $scope): array ->identifier('phpunit.assertEmpty'); foreach ($node->getArgs() as $arg) { - if ($arg->name !== null) { + if ($arg->name !== null || $arg->unpack) { return [$errorBuilder->build()]; } } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php index 07eb77ce..580059c6 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -20,6 +20,7 @@ final class AssertEmptyTest extends TestCase * @param string|int $union */ public function test( + array $arguments, bool $boolean, array $array, int $integer, @@ -37,6 +38,7 @@ public function test( $mixed ): void { + $this->assertEmpty(...$arguments); $this->assertEmpty($boolean); $this->assertNotEmpty($boolean); $this->assertEmpty($array, 'message'); diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed index abe72ce8..c53a704a 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -20,6 +20,7 @@ final class AssertEmptyTest extends TestCase * @param string|int $union */ public function test( + array $arguments, bool $boolean, array $array, int $integer, @@ -37,6 +38,7 @@ final class AssertEmptyTest extends TestCase $mixed ): void { + $this->assertEmpty(...$arguments); $this->assertFalse($boolean); $this->assertTrue($boolean); $this->assertCount(0, $array, 'message'); From 188f6a6b714b505d60816e5ff163594e9f368c73 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 6 Oct 2026 10:32:50 +0200 Subject: [PATCH 10/13] reuse helper --- src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index a2aa0a61..60e20689 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -52,10 +52,8 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder = RuleErrorBuilder::message(sprintf('%s() is not allowed. Use more strict assertion.', $node->name->toString())) ->identifier('phpunit.assertEmpty'); - foreach ($node->getArgs() as $arg) { - if ($arg->name !== null || $arg->unpack) { - return [$errorBuilder->build()]; - } + if (AssertRuleHelper::hasNamedOrUnpackedArguments($node)) { + return [$errorBuilder->build()]; } $replacement = $this->getReplacement($scope->getNativeType($node->getArgs()[0]->value), $node->name->toLowerString() === 'assertnotempty'); From e98de6aacc18c8b5172dfcc3af62537ea41907b1 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 6 Oct 2026 10:51:35 +0200 Subject: [PATCH 11/13] keep more object cases unchanged to prevent regressions --- .../PHPUnit/AssertEmptyIsDiscouragedRule.php | 17 +++++- .../assert-empty-is-discouraged-fixable.php | 60 +++++++++++++++++++ ...ert-empty-is-discouraged-fixable.php.fixed | 60 +++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index 60e20689..0e2e8d08 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\PHPUnit; +use Countable; use PhpParser\Node; use PhpParser\Node\Expr\CallLike; use PhpParser\Node\Expr\MethodCall; @@ -88,10 +89,22 @@ private function getReplacement(Type $type, bool $negated): ?array return null; } foreach ($classReflections as $classReflection) { - // some builtin classes like SimpleXmlElement have different empty() semantics - if ($classReflection->isBuiltin()) { + if ( + $classReflection->isBuiltin() + || !$classReflection->isFinal() + || $classReflection->implementsInterface(Countable::class) + ) { return null; } + + $parentClass = $classReflection->getParentClass(); + while ($parentClass !== null) { + // Builtin parents can define different empty() semantics. + if ($parentClass->isBuiltin()) { + return null; + } + $parentClass = $parentClass->getParentClass(); + } } return [$negated ? 'assertNotNull' : 'assertNull', null]; diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php index 580059c6..d0e99d46 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -10,6 +10,51 @@ final class UserDefinedObject } +class UserDefinedCountable implements \Countable +{ + + public function count(): int + { + return 0; + } + +} + +class OpenParent +{ + +} + +interface Thing +{ + +} + +class ThingImplementation implements Thing, \Countable +{ + + public function count(): int + { + return 0; + } + +} + +final class FinalEmptyIterator extends \EmptyIterator +{ + +} + +final class FinalCountable implements \Countable +{ + + public function count(): int + { + return 0; + } + +} + final class AssertEmptyTest extends TestCase { @@ -34,6 +79,11 @@ public function test( ?\SimpleXMLElement $nullableSimpleXml, ?UserDefinedObject $nullableUserDefinedObject, ?UserDefinedObject $otherNullableUserDefinedObject, + ?UserDefinedCountable $nullableCountable, + ?OpenParent $nullableOpenParent, + ?Thing $nullableThing, + ?FinalEmptyIterator $nullableEmptyIterator, + ?FinalCountable $nullableFinalCountable, $phpDocInteger, $mixed ): void @@ -58,6 +108,16 @@ public function test( $this->assertEmpty($nullableSimpleXml); $this->assertEmpty($nullableUserDefinedObject); $this->assertNotEmpty($otherNullableUserDefinedObject); + $this->assertEmpty($nullableCountable); + $this->assertNotEmpty($nullableCountable); + $this->assertEmpty($nullableOpenParent); + $this->assertNotEmpty($nullableOpenParent); + $this->assertEmpty($nullableThing); + $this->assertNotEmpty($nullableThing); + $this->assertEmpty($nullableEmptyIterator); + $this->assertNotEmpty($nullableEmptyIterator); + $this->assertEmpty($nullableFinalCountable); + $this->assertNotEmpty($nullableFinalCountable); $this->assertNotEmpty($phpDocInteger); $this->assertEmpty($mixed); } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed index c53a704a..a2e65ad0 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -10,6 +10,51 @@ final class UserDefinedObject } +class UserDefinedCountable implements \Countable +{ + + public function count(): int + { + return 0; + } + +} + +class OpenParent +{ + +} + +interface Thing +{ + +} + +class ThingImplementation implements Thing, \Countable +{ + + public function count(): int + { + return 0; + } + +} + +final class FinalEmptyIterator extends \EmptyIterator +{ + +} + +final class FinalCountable implements \Countable +{ + + public function count(): int + { + return 0; + } + +} + final class AssertEmptyTest extends TestCase { @@ -34,6 +79,11 @@ final class AssertEmptyTest extends TestCase ?\SimpleXMLElement $nullableSimpleXml, ?UserDefinedObject $nullableUserDefinedObject, ?UserDefinedObject $otherNullableUserDefinedObject, + ?UserDefinedCountable $nullableCountable, + ?OpenParent $nullableOpenParent, + ?Thing $nullableThing, + ?FinalEmptyIterator $nullableEmptyIterator, + ?FinalCountable $nullableFinalCountable, $phpDocInteger, $mixed ): void @@ -58,6 +108,16 @@ final class AssertEmptyTest extends TestCase $this->assertEmpty($nullableSimpleXml); $this->assertNull($nullableUserDefinedObject); $this->assertNotNull($otherNullableUserDefinedObject); + $this->assertEmpty($nullableCountable); + $this->assertNotEmpty($nullableCountable); + $this->assertEmpty($nullableOpenParent); + $this->assertNotEmpty($nullableOpenParent); + $this->assertEmpty($nullableThing); + $this->assertNotEmpty($nullableThing); + $this->assertEmpty($nullableEmptyIterator); + $this->assertNotEmpty($nullableEmptyIterator); + $this->assertEmpty($nullableFinalCountable); + $this->assertNotEmpty($nullableFinalCountable); $this->assertNotEmpty($phpDocInteger); $this->assertEmpty($mixed); } From 9ec321972bd67e6cf5da7651faee1c219cc2b9af Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 6 Oct 2026 11:00:39 +0200 Subject: [PATCH 12/13] tests unions of scalars and objects get skipped --- .../data/assert-empty-is-discouraged-native-union.php | 8 ++++++++ .../assert-empty-is-discouraged-native-union.php.fixed | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php index e287f749..b6ec90d5 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php @@ -4,6 +4,11 @@ use PHPUnit\Framework\TestCase; +final class UserDefinedObject +{ + +} + final class AssertEmptyTest extends TestCase { @@ -11,6 +16,7 @@ public function test( string|int $value, array|bool $otherValue, \stdClass|int|null $nullableUnion, + UserDefinedObject|int|null $userDefinedNullableUnion, bool $boolean, int $integer ): void @@ -18,6 +24,8 @@ public function test( $this->assertEmpty($value); $this->assertNotEmpty($otherValue); $this->assertEmpty($nullableUnion); + $this->assertEmpty($userDefinedNullableUnion); + $this->assertNotEmpty($userDefinedNullableUnion); $this->assertEmpty(actual: $boolean); $this->assertEmpty(message: 'message', actual: $integer); } diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed index e287f749..b6ec90d5 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-native-union.php.fixed @@ -4,6 +4,11 @@ namespace AssertEmptyIsDiscouragedNativeUnionTest; use PHPUnit\Framework\TestCase; +final class UserDefinedObject +{ + +} + final class AssertEmptyTest extends TestCase { @@ -11,6 +16,7 @@ final class AssertEmptyTest extends TestCase string|int $value, array|bool $otherValue, \stdClass|int|null $nullableUnion, + UserDefinedObject|int|null $userDefinedNullableUnion, bool $boolean, int $integer ): void @@ -18,6 +24,8 @@ final class AssertEmptyTest extends TestCase $this->assertEmpty($value); $this->assertNotEmpty($otherValue); $this->assertEmpty($nullableUnion); + $this->assertEmpty($userDefinedNullableUnion); + $this->assertNotEmpty($userDefinedNullableUnion); $this->assertEmpty(actual: $boolean); $this->assertEmpty(message: 'message', actual: $integer); } From 89814b5d2c6b5404f7c4d395a6a355f1a631e922 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 6 Oct 2026 12:24:32 +0200 Subject: [PATCH 13/13] ignore final by keyword --- src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php | 2 +- .../PHPUnit/data/assert-empty-is-discouraged-fixable.php | 9 +++++++++ .../data/assert-empty-is-discouraged-fixable.php.fixed | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php index 0e2e8d08..b0ebf231 100644 --- a/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php +++ b/src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php @@ -91,7 +91,7 @@ private function getReplacement(Type $type, bool $negated): ?array foreach ($classReflections as $classReflection) { if ( $classReflection->isBuiltin() - || !$classReflection->isFinal() + || !$classReflection->isFinalByKeyword() || $classReflection->implementsInterface(Countable::class) ) { return null; diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php index d0e99d46..96cd5515 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php @@ -10,6 +10,12 @@ final class UserDefinedObject } +/** @final */ +class PhpDocFinalObject +{ + +} + class UserDefinedCountable implements \Countable { @@ -79,6 +85,7 @@ public function test( ?\SimpleXMLElement $nullableSimpleXml, ?UserDefinedObject $nullableUserDefinedObject, ?UserDefinedObject $otherNullableUserDefinedObject, + ?PhpDocFinalObject $nullablePhpDocFinalObject, ?UserDefinedCountable $nullableCountable, ?OpenParent $nullableOpenParent, ?Thing $nullableThing, @@ -108,6 +115,8 @@ public function test( $this->assertEmpty($nullableSimpleXml); $this->assertEmpty($nullableUserDefinedObject); $this->assertNotEmpty($otherNullableUserDefinedObject); + $this->assertEmpty($nullablePhpDocFinalObject); + $this->assertNotEmpty($nullablePhpDocFinalObject); $this->assertEmpty($nullableCountable); $this->assertNotEmpty($nullableCountable); $this->assertEmpty($nullableOpenParent); diff --git a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed index a2e65ad0..70954d89 100644 --- a/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed +++ b/tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php.fixed @@ -10,6 +10,12 @@ final class UserDefinedObject } +/** @final */ +class PhpDocFinalObject +{ + +} + class UserDefinedCountable implements \Countable { @@ -79,6 +85,7 @@ final class AssertEmptyTest extends TestCase ?\SimpleXMLElement $nullableSimpleXml, ?UserDefinedObject $nullableUserDefinedObject, ?UserDefinedObject $otherNullableUserDefinedObject, + ?PhpDocFinalObject $nullablePhpDocFinalObject, ?UserDefinedCountable $nullableCountable, ?OpenParent $nullableOpenParent, ?Thing $nullableThing, @@ -108,6 +115,8 @@ final class AssertEmptyTest extends TestCase $this->assertEmpty($nullableSimpleXml); $this->assertNull($nullableUserDefinedObject); $this->assertNotNull($otherNullableUserDefinedObject); + $this->assertEmpty($nullablePhpDocFinalObject); + $this->assertNotEmpty($nullablePhpDocFinalObject); $this->assertEmpty($nullableCountable); $this->assertNotEmpty($nullableCountable); $this->assertEmpty($nullableOpenParent);