From dd3900656ba3979a75e2fe5c6405d32f74b1a6fd Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 23 Oct 2026 11:57:19 +0200 Subject: [PATCH] Skip CommandMustHaveAsCommandAttributeRule when symfony/console not in require --- README.md | 2 +- config/symfony-rules.neon | 5 ++ src/Composer/ComposerRequireResolver.php | 39 ++++++++++++++ .../CommandMustHaveAsCommandAttributeRule.php | 53 +++++-------------- .../Composer/ComposerRequireResolverTest.php | 32 +++++++++++ .../Fixture/with-require-dev/composer.json | 5 ++ .../Fixture/with-require/composer.json | 5 ++ tests/Composer/Fixture/without/composer.json | 5 ++ .../composer-fixture/composer.json | 5 ++ .../config/configured_rule.neon | 6 +++ 10 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 src/Composer/ComposerRequireResolver.php create mode 100644 tests/Composer/ComposerRequireResolverTest.php create mode 100644 tests/Composer/Fixture/with-require-dev/composer.json create mode 100644 tests/Composer/Fixture/with-require/composer.json create mode 100644 tests/Composer/Fixture/without/composer.json create mode 100644 tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/composer-fixture/composer.json diff --git a/README.md b/README.md index 4a861aa6..ece36cbe 100644 --- a/README.md +++ b/README.md @@ -2607,7 +2607,7 @@ return function (ContainerConfigurator $container) { ### CommandMustHaveAsCommandAttributeRule -Every class that extends Symfony `Command` must declare the `#[AsCommand]` attribute. +Every class that extends Symfony `Command` must declare the `#[AsCommand]` attribute. Skipped when `symfony/console` is not a `require` dependency (a `require-dev` only entry is ignored). ```yaml rules: diff --git a/config/symfony-rules.neon b/config/symfony-rules.neon index 3711249a..40941ccd 100644 --- a/config/symfony-rules.neon +++ b/config/symfony-rules.neon @@ -57,6 +57,11 @@ conditionalTags: services: - Symplify\PHPStanRules\NodeAnalyzer\LaravelPresenceResolver + - + class: Symplify\PHPStanRules\Composer\ComposerRequireResolver + arguments: + workingDirectory: %currentWorkingDirectory% + - Symplify\PHPStanRules\Rules\Symfony\SingleArgEventDispatchRule - Symplify\PHPStanRules\Rules\Symfony\NoListenerWithoutContractRule - Symplify\PHPStanRules\Rules\Symfony\NoStringInGetSubscribedEventsRule diff --git a/src/Composer/ComposerRequireResolver.php b/src/Composer/ComposerRequireResolver.php new file mode 100644 index 00000000..58256cd1 --- /dev/null +++ b/src/Composer/ComposerRequireResolver.php @@ -0,0 +1,39 @@ +workingDirectory = $workingDirectory; + } + + public function hasRequiredPackage(string $packageName): bool + { + $composerJsonFilePath = $this->workingDirectory . '/composer.json'; + if (! is_file($composerJsonFilePath)) { + return false; + } + + $composerJson = json_decode((string) file_get_contents($composerJsonFilePath), true); + if (! is_array($composerJson)) { + return false; + } + + // only the "require" section counts, "require-dev" is skipped on purpose + $require = $composerJson['require'] ?? []; + if (! is_array($require)) { + return false; + } + + return isset($require[$packageName]); + } +} diff --git a/src/Rules/Symfony/CommandMustHaveAsCommandAttributeRule.php b/src/Rules/Symfony/CommandMustHaveAsCommandAttributeRule.php index dddd7bfe..ee327a55 100644 --- a/src/Rules/Symfony/CommandMustHaveAsCommandAttributeRule.php +++ b/src/Rules/Symfony/CommandMustHaveAsCommandAttributeRule.php @@ -13,6 +13,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use Symfony\Component\Console\Command\Command; +use Symplify\PHPStanRules\Composer\ComposerRequireResolver; use Symplify\PHPStanRules\Rules\Enum\SymfonyAttribute; /** @@ -28,9 +29,9 @@ final class CommandMustHaveAsCommandAttributeRule implements Rule private ReflectionProvider $reflectionProvider; /** - * @var string + * @readonly */ - private const LARAVEL_COMMAND_CLASS = 'Illuminate\Console\Command'; + private ComposerRequireResolver $composerRequireResolver; /** * @var string @@ -40,13 +41,17 @@ final class CommandMustHaveAsCommandAttributeRule implements Rule /** * @var string */ - public const ERROR_MESSAGE = 'Class "%s" extends Command but is missing the #[AsCommand] attribute'; + private const LARAVEL_COMMAND_CLASS = 'Illuminate\Console\Command'; - private ?bool $hasSymfonyConsolePackage = null; + /** + * @var string + */ + public const ERROR_MESSAGE = 'Class "%s" extends Command but is missing the #[AsCommand] attribute'; - public function __construct(ReflectionProvider $reflectionProvider) + public function __construct(ReflectionProvider $reflectionProvider, ComposerRequireResolver $composerRequireResolver) { $this->reflectionProvider = $reflectionProvider; + $this->composerRequireResolver = $composerRequireResolver; } public function getNodeType(): string @@ -66,8 +71,8 @@ public function processNode(Node $node, Scope $scope): array return []; } - // only projects that depend on symfony/console in composer.json - if (! $this->hasSymfonyConsolePackage()) { + // symfony/console must be a direct production dependency, require-dev is skipped + if (! $this->composerRequireResolver->hasRequiredPackage(self::SYMFONY_CONSOLE_PACKAGE)) { return []; } @@ -109,38 +114,4 @@ public function processNode(Node $node, Scope $scope): array return [$identifierRuleError]; } - - private function hasSymfonyConsolePackage(): bool - { - if ($this->hasSymfonyConsolePackage !== null) { - return $this->hasSymfonyConsolePackage; - } - - $composerJsonFilePath = getcwd() . '/composer.json'; - if (! is_file($composerJsonFilePath)) { - return $this->hasSymfonyConsolePackage = false; - } - - $composerJsonContents = file_get_contents($composerJsonFilePath); - if ($composerJsonContents === false) { - return $this->hasSymfonyConsolePackage = false; - } - - $composerJson = json_decode($composerJsonContents, true); - if (! is_array($composerJson)) { - return $this->hasSymfonyConsolePackage = false; - } - - $requirePackages = $composerJson['require'] ?? []; - if (is_array($requirePackages) && isset($requirePackages[self::SYMFONY_CONSOLE_PACKAGE])) { - return $this->hasSymfonyConsolePackage = true; - } - - $requireDevPackages = $composerJson['require-dev'] ?? []; - if (is_array($requireDevPackages) && isset($requireDevPackages[self::SYMFONY_CONSOLE_PACKAGE])) { - return $this->hasSymfonyConsolePackage = true; - } - - return $this->hasSymfonyConsolePackage = false; - } } diff --git a/tests/Composer/ComposerRequireResolverTest.php b/tests/Composer/ComposerRequireResolverTest.php new file mode 100644 index 00000000..850ddcd1 --- /dev/null +++ b/tests/Composer/ComposerRequireResolverTest.php @@ -0,0 +1,32 @@ +assertSame($expectedHasPackage, $composerRequireResolver->hasRequiredPackage('symfony/console')); + } + + /** + * @return Iterator + */ + public static function provideData(): Iterator + { + yield [__DIR__ . '/Fixture/with-require', true]; + yield [__DIR__ . '/Fixture/with-require-dev', false]; + yield [__DIR__ . '/Fixture/without', false]; + yield [__DIR__ . '/Fixture/non-existing-directory', false]; + } +} diff --git a/tests/Composer/Fixture/with-require-dev/composer.json b/tests/Composer/Fixture/with-require-dev/composer.json new file mode 100644 index 00000000..f9cb8568 --- /dev/null +++ b/tests/Composer/Fixture/with-require-dev/composer.json @@ -0,0 +1,5 @@ +{ + "require-dev": { + "symfony/console": "^7.0" + } +} diff --git a/tests/Composer/Fixture/with-require/composer.json b/tests/Composer/Fixture/with-require/composer.json new file mode 100644 index 00000000..ae8c4858 --- /dev/null +++ b/tests/Composer/Fixture/with-require/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "symfony/console": "^7.0" + } +} diff --git a/tests/Composer/Fixture/without/composer.json b/tests/Composer/Fixture/without/composer.json new file mode 100644 index 00000000..57b5e765 --- /dev/null +++ b/tests/Composer/Fixture/without/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": ">=8.3" + } +} diff --git a/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/composer-fixture/composer.json b/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/composer-fixture/composer.json new file mode 100644 index 00000000..ae8c4858 --- /dev/null +++ b/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/composer-fixture/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "symfony/console": "^7.0" + } +} diff --git a/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/config/configured_rule.neon b/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/config/configured_rule.neon index 373e05e0..11f5d783 100644 --- a/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/config/configured_rule.neon +++ b/tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/config/configured_rule.neon @@ -1,5 +1,11 @@ includes: - ../../../../config/included_services.neon +services: + - + class: Symplify\PHPStanRules\Composer\ComposerRequireResolver + arguments: + workingDirectory: 'tests/Rules/Symfony/CommandMustHaveAsCommandAttributeRule/composer-fixture' + rules: - Symplify\PHPStanRules\Rules\Symfony\CommandMustHaveAsCommandAttributeRule