Skip to content

AssertEmptyIsDiscouragedRule is auto-fixable - #342

Open
staabm wants to merge 13 commits into
phpstan:2.1.xfrom
staabm:auto-f
Open

staabm wants to merge 13 commits into
phpstan:2.1.xfrom
staabm:auto-f

Conversation

@staabm

@staabm staabm commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

closes #338

support scalar types. we skip all regular strings which are not non-falsey, because fixing these would yield ugly code like $s == '' || $s == '0'.

we only support the easiest cases for now which have a unambiguous fix and lead more readable code.

@staabm

staabm commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

//cc @SanderMuller

@staabm
staabm requested a review from VincentLanglet October 5, 2026 12:44

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the fixes against what assertEmpty() does at runtime, at head e7037550f. make tests and make phpstan pass. In five cases the fixed assertion behaves differently from the original. I ran each original and fixed line with real PHPUnit 9.6:

Case Original Fixed Original result Fixed result
@param int $x, null at runtime assertNotEmpty($x) assertNotSame(0, $x) fails passes
@param non-falsy-string $x, '0' at runtime assertNotEmpty($x) assertNotSame('', $x) fails passes
?\SimpleXMLElement $x, <a/> assertEmpty($x) assertNull($x) passes fails
bool $b assertEmpty(actual: $b) assertFalse(actual: $b) passes Error: Unknown named parameter $actual
int $i, 0 assertEmpty(message: 'm', actual: $i) assertSame('', message: 'm', actual: $i) passes fails
  1. The type comes from $scope->getType(), so a PHPDoc type decides the fix. In a test, the assertion is often what checks that the PHPDoc is right. The negated fixes then accept values that the original rejected (the first two rows). $scope->getNativeType() would avoid that. The non-falsy-string case would then not be fixed at all, but assertEmpty() on a non-falsy string can never pass anyway.

  2. An empty SimpleXMLElement is empty for empty(), so assertNull() is not the same. ?object has the same problem, because the object can be a SimpleXMLElement at runtime. Fixing only when (new ObjectType(\SimpleXMLElement::class))->isSuperTypeOf(...) is no() for the type without null would keep ?\stdClass and skip both.

  3. The rule reads getArgs()[0] as the asserted value and keeps the arguments as they are:

    • With actual: as a named argument, assertFalse(), assertTrue(), assertCount() and assertNotCount() throw an Error, because their parameter is $condition or $haystack. assertSame(), assertNotSame(), assertNull() and assertNotNull() have $actual, so they work.
    • With message: first, the message string is treated as the asserted value, and the fix becomes assertSame('', ...).

    Skipping the fix when any argument is named would cover both.

The other fixes are equivalent for their types, including the extra message argument (assertCount(0, $array, 'message')). float, plain string and unions without null stay unfixed, as the description says.

The three Mutation Testing reds come from this PR. Infection reports 4 escaped mutants, which change ->yes() to !->no() on the isObject(), isBoolean(), isArray() and isInteger() checks. So the tests have no value for which one of these checks is a "maybe" and the fix must not apply. The other CI checks pass.

I only checked the parameter names in PHPUnit 9.6, not in 10 to 12.

@staabm
staabm requested review from SanderMuller and a balanced review from Copilot and removed request for SanderMuller October 5, 2026 14:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Automatic fixes can change assertion behavior for unpacked arguments, nullable object/scalar unions, and Countable objects.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
What changed in this PR

Adds automatic fixes to PHPStan’s discouraged empty-assertion rule, replacing supported cases with more specific PHPUnit assertions.

Changes:

  • Selects replacements using native argument types.
  • Adds fix fixtures and tests for supported and unchanged cases.
File Description
tests/​Rules/​PHPUnit/​data/​assert-empty-is-discouraged-native-union.php.fixed Expected unchanged union and named-argument assertions.
tests/​Rules/​PHPUnit/​data/​assert-empty-is-discouraged-native-union.php Union and named-argument fixtures.
tests/​Rules/​PHPUnit/​data/​assert-empty-is-discouraged-fixable.php.fixed Expected assertion replacements.
tests/​Rules/​PHPUnit/​data/​assert-empty-is-discouraged-fixable.php Supported and unsupported type fixtures.
tests/​Rules/​PHPUnit/​AssertEmptyIsDiscouragedRuleTest.php Adds automatic-fix tests.
src/​Rules/​PHPUnit/​AssertEmptyIsDiscouragedRule.php Implements type-based assertion fixes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php Outdated
Comment thread src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php
Comment thread src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php Outdated

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked b207aedaf again. The PHPDoc, SimpleXMLElement, named-argument and mutation points from my first review are fixed, and the tests, PHPStan and CI pass.

The nullable-object fix still changes results. assertEmpty() also treats an EmptyIterator and a Countable with a count of 0 as empty. isBuiltin() only looks at the declared class, and a subclass or an implementation can add either. I ran the rule's fix on these parameter types and then ran each original and fixed line with PHPUnit 9.6:

Parameter Value at runtime Original Fixed
?Collection (user class, Countable) count() is 0 assertEmpty() passes assertNull() fails
?MyIterator (extends EmptyIterator) new instance passes fails
?MyArrayObject (extends ArrayObject) new, empty passes fails
?OpenParent (non-final class) a Countable subclass, count 0 passes fails
?Thing (interface) a Countable implementation, count 0 passes fails
?Foo (non-final class) a Countable subclass, count 0 assertNotEmpty() fails assertNotNull() passes
?FinalFoo (final user class) new instance assertEmpty() fails assertNull() fails

Only the final class gives the same result. So the null fix could apply only when each class is final, does not implement Countable, and has no builtin parent. That covers EmptyIterator, ArrayObject and SimpleXMLElement subclasses too.

Copilot's UserDefinedObject|int|null case does not reproduce. The rule leaves Foo|int|null as it is, because getObjectClassReflections() returns nothing when a union member is not an object.

I ran the cases with PHPUnit 9.6 only. The IsEmpty constraint has the same EmptyIterator and Countable checks in 10.5, 11.5 and 12.0.

@staabm staabm closed this Oct 6, 2026
@staabm
staabm deleted the auto-f branch October 6, 2026 08:33
@staabm
staabm restored the auto-f branch October 6, 2026 08:36
@staabm staabm reopened this Oct 6, 2026
@staabm
staabm requested a balanced review from Copilot October 6, 2026 08:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Nullable unions containing objects and scalars can receive automatic fixes that change assertion outcomes.

Review effort: Balanced
Findings: None

Resolved since last review (3)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Trusting PHPDoc finality allows nullable-object fixes that change assertion behavior for runtime subclasses.

Review effort: Balanced
Findings: None

Previously missed (1)

In code that hasn't changed since last review

Medium severity Require native finality to avoid incorrect nullable class assertions

src/​Rules/​PHPUnit/​AssertEmptyIsDiscouragedRule.php:94

isFinal() accepts PHPDoc @final, which does not prevent runtime subclasses. For a nullable class annotated this way, a subclass implementing Countable with count zero passes assertEmpty() but fails the generated assertNull(); the negated rewrite also changes behavior. Use isFinalByKeyword() to require native finality, consistent with this fixer's use of native types.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Nullable unions containing scalar members can receive autofixes that change assertion behavior.

Review effort: Balanced
Findings: None

@staabm
staabm requested a review from SanderMuller October 6, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

make AssertEmptyIsDiscouragedRule autofixable

4 participants