shipmonk/dead-code-detector

Dead code detector to find unused PHP code via PHPStan extension.

Installs: 20 600

Dependents: 5

Suggesters: 0

Security: 0

Stars: 151

Watchers: 4

Forks: 9

Open Issues: 7

Type:phpstan-extension

0.7.0 2024-12-23 10:24 UTC

README

PHPStan extension to find unused PHP code in your project with ease!

Summary:

  • โœ… PHPStan extension
  • โ™ป๏ธ Dead cycles detection
  • ๐Ÿ”— Transitive dead member detection
  • ๐Ÿงน Automatic removal of unused code
  • ๐Ÿ“š Popular libraries support
  • โœจ Customizable usage providers

Installation:

composer require --dev shipmonk/dead-code-detector

Use official extension-installer or just load the rules:

# phpstan.neon.dist
includes:
    - vendor/shipmonk/dead-code-detector/rules.neon

Supported libraries:

Symfony:

  • Calls made by DIC over your services!
  • #[AsEventListener] attribute
  • #[AsController] attribute
  • #[AsCommand] attribute
  • #[Required] attribute
  • #[Route] attributes
  • EventSubscriberInterface::getSubscribedEvents
  • onKernelResponse, onKernelRequest, etc

Doctrine:

  • #[AsEntityListener] attribute
  • Doctrine\ORM\Events::* events
  • Doctrine\Common\EventSubscriber methods
  • lifecycle event attributes #[PreFlush], #[PostLoad], ...

PHPUnit:

  • data provider methods
  • testXxx methods
  • annotations like @test, @before, @afterClass etc
  • attributes like #[Test], #[Before], #[AfterClass] etc

PHPStan:

  • constructor calls for DIC services (rules, extensions, ...)

Nette:

  • handleXxx, renderXxx, actionXxx, injectXxx, createComponentXxx
  • SmartObject magic calls for @property annotations

All those libraries are autoenabled when found within your composer dependencies. If you want to force enable/disable some of them, you can:

# phpstan.neon.dist
parameters:
    shipmonkDeadCode:
        usageProviders:
            phpunit:
                enabled: true

Generic usage providers:

Reflection:

  • Any constant or method accessed via ReflectionClass is detected as used
    • e.g. $reflection->getConstructor(), $reflection->getConstant('NAME'), $reflection->getMethods(), ...

Vendor:

  • Any overridden method that originates in vendor is not reported as dead
    • e.g. implementing Psr\Log\LoggerInterface::log is automatically considered used

Those providers are enabled by default, but you can disable them if needed.

Customization:

  • If your application does some magic calls unknown to this library, you can implement your own usage provider.
  • Just tag it with shipmonk.deadCode.memberUsageProvider and implement ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider
# phpstan.neon.dist
services:
    -
        class: App\ApiOutputUsageProvider
        tags:
            - shipmonk.deadCode.memberUsageProvider

Important

The interface & tag changed in 0.7. If you are using PHPStan 1.x, those were used differently.

Reflection-based customization:

  • For simple reflection usecases, you can just extend ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider:
use ReflectionMethod;
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;

class ApiOutputUsageProvider extends ReflectionBasedMemberUsageProvider
{

    public function shouldMarkMethodAsUsed(ReflectionMethod $method): bool
    {
        // all methods from our ApiOutput interface are called automatically (e.g. during serialization)
        return $method->getDeclaringClass()->implementsInterface(ApiOutput::class);
    }

}

AST-based customization:

  • For more complex usecases that are deducible only from AST (e.g. serialization calls), you just stick with raw MemberUsageProvider interface:
use ReflectionMethod;
use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef;
use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage;
use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider;
use Symfony\Component\Serializer\SerializerInterface;

class DeserializationUsageProvider implements MemberUsageProvider
{

    /**
     * @return list<ClassMemberUsage>
     */
    public function getUsages(Node $node, Scope $scope): array
    {
        if (!$node instanceof MethodCall) {
            return [];
        }

        if (
            // our deserialization calls constructor
            $scope->getType($node->var)->getObjectClassNames() === [SerializerInterface::class] &&
            $node->name->toString() === 'deserialize'
        ) {
            $secondArgument = $node->getArgs()[1]->value;
            $serializedClass = $scope->getType($secondArgument)->getConstantStrings()[0];

            // record the method it was called from (needed for proper transitive dead code elimination)
            $originRef = $this->getOriginMethodRef($scope);

            // record the hidden constructor call
            $constructorRef = new ClassMethodRef($serializedClass->getValue(), '__construct', false);

            return [new ClassMethodUsage($originRef, $constructorRef)];
        }

        return [];
    }

    private function getOriginMethodRef(Scope $scope): ?ClassMethodRef
    {
        return new ClassMethodRef(
            $scope->getClassReflection()->getName(),
            $scope->getFunction()->getName(),
            false,
        );
    }

}

Dead cycles & transitively dead methods

  • This library automatically detects dead cycles and transitively dead methods (methods that are only called from dead methods)
  • By default, it reports only the first dead method in the subtree and the rest as a tip:
 ------ ------------------------------------------------------------------------
  Line   src/App/Facade/UserFacade.php
 ------ ------------------------------------------------------------------------
  26     Unused App\Facade\UserFacade::updateUserAddress
         ๐Ÿชช  shipmonk.deadMethod
         ๐Ÿ’ก Thus App\Entity\User::updateAddress is transitively also unused
         ๐Ÿ’ก Thus App\Entity\Address::setPostalCode is transitively also unused
         ๐Ÿ’ก Thus App\Entity\Address::setCountry is transitively also unused
         ๐Ÿ’ก Thus App\Entity\Address::setStreet is transitively also unused
         ๐Ÿ’ก Thus App\Entity\Address::MAX_STREET_CHARS is transitively also unused
 ------ ------------------------------------------------------------------------
  • If you want to report all dead methods individually, you can enable it in your phpstan.neon.dist:
parameters:
    shipmonkDeadCode:
        reportTransitivelyDeadMethodAsSeparateError: true

Automatic removal of dead code

  • If you are sure that the reported methods are dead, you can automatically remove them by running PHPStan with removeDeadCode error format:
vendor/bin/phpstan analyse --error-format removeDeadCode
class UserFacade
{
-    public const TRANSITIVELY_DEAD = 1;
-
-    public function deadMethod(): void
-    {
-        echo self::TRANSITIVELY_DEAD;
-    }
}

Calls over unknown types

  • In order to prevent false positives, we support even calls over unknown types (e.g. $unknown->method()) by marking all methods named method as used
    • Such behaviour might not be desired for strictly typed codebases, because e.g. single new $unknown() will mark all constructors as used
    • Thus, you can disable this feature in your phpstan.neon.dist:
  • The same applies to constant fetches over unknown types (e.g. $unknown::CONSTANT)
parameters:
    shipmonkDeadCode:
        trackMixedAccess: false
  • If you want to check how many of those cases are present in your codebase, you can run PHPStan analysis with -vvv and you will see some diagnostics:
Found 2 usages over unknown type:
 โ€ข setCountry method, for example in App\Entity\User::updateAddress
 โ€ข setStreet method, for example in App\Entity\User::updateAddress

Comparison with tomasvotruba/unused-public

  • You can see detailed comparison PR
  • Basically, their analysis is less precise and less flexible. Mainly:
    • It cannot detect dead constructors
    • It does not properly detect calls within inheritance hierarchy
    • It does not offer any custom adjustments of used methods
    • It has almost no built-in library extensions
    • It ignores trait methods
    • Is lacks many minor features like class-string calls, dynamic method calls, array callbacks, nullsafe call chains etc
    • It cannot detect dead cycles nor transitively dead methods
    • It has no built-in dead code removal

Limitations:

  • Methods of anonymous classes are never reported as dead (PHPStan limitation)
  • Abstract trait methods are never reported as dead
  • Most magic methods (e.g. __get, __set etc) are never reported as dead
    • Only supported are: __construct, __clone

Other problematic cases:

Constructors:

  • For symfony apps & PHPStan extensions, we simplify the detection by assuming all DIC classes have used constructor.
  • For other apps, you may get false-positives if services are created magically.
    • To avoid those, you can easily disable constructor analysis with single ignore:
parameters:
    ignoreErrors:
        - '#^Unused .*?::__construct$#'

Private constructors:

  • Those are never reported as dead as those are often used to deny class instantiation

Interface methods:

  • If you never call interface method over the interface, but only over its implementors, it gets reported as dead
  • But you may want to keep the interface method to force some unification across implementors
    • The easiest way to ignore it is via custom MemberUsageProvider:
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;

class IgnoreDeadInterfaceUsageProvider extends ReflectionBasedMemberUsageProvider
{
    public function shouldMarkMethodAsUsed(ReflectionMethod $method): bool
    {
        return $method->getDeclaringClass()->isInterface();
    }
}

Future scope:

  • Dead class property detection
  • Dead class detection

Contributing

  • Check your code by composer check
  • Autofix coding-style by composer fix:cs
  • All functionality must be tested

Supported PHP versions

  • PHP 7.4 - 8.4