⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

Conversation

@fain182
Copy link
Collaborator

@fain182 fain182 commented Jan 25, 2026

Summary

Fixes #560 - IsNotAbstract (and other similar expressions) incorrectly excluded final classes when used in that() clauses, preventing subsequent rules from being evaluated.

Problem Description

The Bug

When using IsNotAbstract as a filter in that(), final classes were incorrectly excluded:

Rule::allClasses()
    ->that(new IsNotAbstract())  // BUG: Excluded final classes!
    ->should(new HaveNameMatching('*Test'))
    ->because('all tests must follow naming convention');

Result: Final test classes with wrong names were NOT flagged as violations.

Root Cause

The appliesTo() method was confusing two different concepts:

  1. Type incompatibility: Interface/Trait/Enum don't have the concept of abstract/final → correctly excluded
  2. Mutually exclusive states: Final ↔ Abstract are opposites → incorrectly excluded

Buggy implementation (from PR #478):

class IsNotAbstract {
    public function appliesTo(ClassDescription $theClass): bool {
        return !($theClass->isInterface() || $theClass->isTrait() || 
                 $theClass->isEnum() || $theClass->isFinal());  // ← BUG!
    }
}

The logic error:

  • Final classes ARE non-abstract by definition
  • Excluding them from IsNotAbstract is semantically wrong
  • Same bug existed in IsFinal, IsNotFinal, IsAbstract

Historical Context

Solution

Changed Semantics

New appliesTo() semantics:

"Does it make sense to verify this property for this type of class?"

Rules:

  • ✅ Return true for regular classes, final classes, abstract classes
  • ❌ Return false ONLY for Interface/Trait/Enum (where abstract/final concepts don't apply)
  • ✅ Do NOT exclude mutually exclusive states (abstract ↔ final)

Code Changes

// Before (WRONG)
class IsNotAbstract {
    public function appliesTo(ClassDescription $theClass): bool {
        return !($theClass->isInterface() || $theClass->isTrait() || 
                 $theClass->isEnum() || $theClass->isFinal());
    }
}

// After (CORRECT)
class IsNotAbstract {
    public function appliesTo(ClassDescription $theClass): bool {
        return !($theClass->isInterface() || $theClass->isTrait() || 
                 $theClass->isEnum());
        // Final classes removed - they ARE non-abstract!
    }
}

Same fix applied to:

  • IsAbstract (removed || $theClass->isFinal())
  • IsNotAbstract (removed || $theClass->isFinal())
  • IsFinal (removed || $theClass->isAbstract())
  • IsNotFinal (removed || $theClass->isAbstract())

Why This Is Correct

PHP Semantics

In PHP, abstract and final are mutually exclusive:

  • final class Foo {}isAbstract() always returns false
  • abstract class Bar {}isFinal() always returns false

Rule Evaluation Flow

In that() clauses (filtering):

if (!$spec->appliesTo($classDescription)) {
    return false;  // Skip entire rule chain
}

In should() clauses (validation):

$expression->evaluate($classDescription, $violations, $because);
// appliesTo() is never called here

Expected Behavior Table

Class Type IsAbstract::appliesTo() IsNotAbstract::appliesTo() Why
Regular class true true Can check abstract property
Final class true true Can check abstract property (always false)
Abstract class true true Can check abstract property (always true)
Interface false false Abstract concept doesn't apply
Trait false false Abstract concept doesn't apply
Enum false false Abstract concept doesn't apply

This test replicates the issue described in PR #560 where IsNotAbstract
incorrectly filters out final classes when used in a that() clause.

The test creates a scenario with:
- Final classes with correct/incorrect naming
- Abstract classes (should be filtered out)
- Normal classes with incorrect naming

Expected behavior: IsNotAbstract should include final classes in the
filter since final classes are, by definition, non-abstract.

Current behavior: Test fails with 1 violation instead of expected 2,
demonstrating that final classes are incorrectly excluded.
This test verifies that when IsNotAbstract is used as a constraint in
should() (not as a filter in that()), it correctly handles final classes.

Expected behavior: Final classes should NOT generate violations because
they are, by definition, non-abstract (isAbstract() returns false).

Result: Test PASSES - demonstrates that evaluate() works correctly.
The bug is only in appliesTo(), not in evaluate().
Include interface/trait/enum cases to explicitly document that:
- Only 'abstract class' should generate violations
- Final, normal classes, interfaces, traits, and enums should NOT
  generate violations (isAbstract() returns false for all of them)

This makes the test more comprehensive and prevents future regressions.
This fixes the issue described in PR #560 where IsNotAbstract incorrectly
excluded final classes when used in that() clauses.

Root cause: appliesTo() was implementing two different semantics:
1. Type filtering: "Does this property apply to this type?"
2. Condition checking: "Does this class match the condition?"

This caused final classes to be excluded from IsNotAbstract filters, even
though final classes ARE non-abstract by definition.

Changes:
- IsAbstract/IsNotAbstract: Remove isFinal() check from appliesTo()
  Final classes can be checked for abstractness (they're always non-abstract)

- IsFinal/IsNotFinal: Remove isAbstract() check from appliesTo()
  Abstract classes can be checked for finality (they're always non-final)

New semantics for appliesTo():
- Returns true if the property can be semantically verified for this type
- Only excludes interface/trait/enum where abstract/final concepts don't apply
- Does NOT exclude mutually exclusive states (abstract/final)

All 350 tests pass, including new integration tests that verify:
- IsNotAbstract in that() now includes final classes
- IsNotAbstract in should() correctly validates all class types
Enhance unit tests for IsAbstract/IsFinal to verify not just appliesTo()
but also the actual evaluate() behavior with mutually exclusive states:

- IsAbstract with final class: appliesTo=true, generates violation
- IsNotAbstract with final class: appliesTo=true, no violation
- IsFinal with abstract class: appliesTo=true, generates violation
- IsNotFinal with abstract class: appliesTo=true, no violation

This makes the tests more comprehensive and documents the expected
behavior when these expressions are used in both that() and should()
contexts.
@fain182 fain182 changed the title Allow final and abstract classes to be checked for abstract/final properties Fix: appliesTo() incorrectly excludes mutually exclusive class states Jan 25, 2026
@codecov
Copy link

codecov bot commented Jan 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.80%. Comparing base (c600ddd) to head (806256c).

Additional details and impacted files
@@            Coverage Diff            @@
##               main     #561   +/-   ##
=========================================
  Coverage     97.80%   97.80%           
+ Complexity      632      628    -4     
=========================================
  Files            81       81           
  Lines          1822     1822           
=========================================
  Hits           1782     1782           
  Misses           40       40           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

3 participants