From cc6435149401815855163976997241756cad79bb Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 5 Aug 2026 23:56:36 +0200 Subject: [PATCH 1/2] [Configuration] Report explicit error when --only rule exists but is not registered Instead of "was not found", tell the rule class exists and how to register it. --- src/Configuration/OnlyRuleResolver.php | 134 ++++++++++++++++++- tests/Configuration/OnlyRuleResolverTest.php | 31 ++++- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/Configuration/OnlyRuleResolver.php b/src/Configuration/OnlyRuleResolver.php index 2667ddef002..19f444b6385 100644 --- a/src/Configuration/OnlyRuleResolver.php +++ b/src/Configuration/OnlyRuleResolver.php @@ -4,9 +4,13 @@ namespace Rector\Configuration; +use Composer\Autoload\ClassLoader; +use PHPStan\Reflection\ReflectionProvider; use Rector\Contract\Rector\RectorInterface; use Rector\Exception\Configuration\RectorRuleNameAmbiguousException; use Rector\Exception\Configuration\RectorRuleNotFoundException; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; /** * @see \Rector\Tests\Configuration\OnlyRuleResolverTest @@ -17,7 +21,8 @@ * @param RectorInterface[] $rectors */ public function __construct( - private array $rectors + private array $rectors, + private ReflectionProvider $reflectionProvider ) { } @@ -62,6 +67,12 @@ public function resolve(string $rule): string throw new RectorRuleNameAmbiguousException($message); } + // the rule class exists, it is just missing in the config + $unregisteredRuleClasses = $this->matchUnregisteredRuleClasses($rule); + if ($unregisteredRuleClasses !== []) { + throw new RectorRuleNotFoundException($this->createUnregisteredMessage($rule, $unregisteredRuleClasses)); + } + if (! str_contains($rule, '\\')) { // the shell has eaten unescaped backslashes, e.g. --only=\Rector\Some\Rule $flattenMatching = []; @@ -91,4 +102,125 @@ public function resolve(string $rule): string throw new RectorRuleNotFoundException($message); } + + /** + * Rule classes that exist in the autoloaded code, but are not registered in the config + * + * @return string[] + */ + private function matchUnregisteredRuleClasses(string $rule): array + { + if (str_contains($rule, '\\')) { + return $this->isRectorRuleClass($rule) ? [$rule] : []; + } + + $ruleClasses = []; + + foreach ($this->resolvePsr4Prefixes() as $namespacePrefix => $directories) { + foreach ($directories as $directory) { + if (! is_dir($directory)) { + continue; + } + + $finder = Finder::create() + ->files() + ->in($directory) + ->name($rule . '.php'); + + foreach ($finder as $fileInfo) { + $ruleClass = $this->createClassName($namespacePrefix, $directory, $fileInfo); + if ($this->isRectorRuleClass($ruleClass)) { + $ruleClasses[] = $ruleClass; + } + } + } + } + + $ruleClasses = array_unique($ruleClasses); + sort($ruleClasses); + + return $ruleClasses; + } + + private function isRectorRuleClass(string $className): bool + { + if (! $this->reflectionProvider->hasClass($className)) { + return false; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if ($classReflection->isAbstract()) { + return false; + } + + return $classReflection->implementsInterface(RectorInterface::class); + } + + /** + * Only Rector rule namespaces are worth scanning, as rules always live there + * + * @return array + */ + private function resolvePsr4Prefixes(): array + { + $psr4Prefixes = []; + + foreach (spl_autoload_functions() as $autoloadFunction) { + if (! is_array($autoloadFunction)) { + continue; + } + + $classLoader = $autoloadFunction[0]; + if (! $classLoader instanceof ClassLoader) { + continue; + } + + foreach ($classLoader->getPrefixesPsr4() as $namespacePrefix => $directories) { + if (! str_contains($namespacePrefix, 'Rector')) { + continue; + } + + $psr4Prefixes[$namespacePrefix] = array_merge( + $psr4Prefixes[$namespacePrefix] ?? [], + array_values($directories) + ); + } + } + + return $psr4Prefixes; + } + + private function createClassName(string $namespacePrefix, string $directory, SplFileInfo $fileInfo): string + { + $relativeDirectory = trim(str_replace($directory, '', $fileInfo->getPath()), '/'); + $namespace = $namespacePrefix . str_replace('/', '\\', $relativeDirectory); + + return rtrim($namespace, '\\') . '\\' . $fileInfo->getFilenameWithoutExtension(); + } + + /** + * @param string[] $unregisteredRuleClasses + */ + private function createUnregisteredMessage(string $rule, array $unregisteredRuleClasses): string + { + if (count($unregisteredRuleClasses) === 1) { + $unregisteredRuleClass = $unregisteredRuleClasses[0]; + $shortRuleClass = substr((string) strrchr($unregisteredRuleClass, '\\'), 1); + + return sprintf( + 'Rule "%s" exists, but is not registered in your Rector config.%sRegister it in your rector.php:' + . PHP_EOL . PHP_EOL . ' ->withRules([%s::class])', + $unregisteredRuleClass, + PHP_EOL, + $shortRuleClass + ); + } + + return sprintf( + 'Rule "%s" exists in these classes, but none of them is registered in your Rector config:' . PHP_EOL + . '- ' . implode(PHP_EOL . '- ', $unregisteredRuleClasses) . PHP_EOL + . 'Register one of them in your rector.php', + $rule + ); + } } diff --git a/tests/Configuration/OnlyRuleResolverTest.php b/tests/Configuration/OnlyRuleResolverTest.php index 8af2df9fd26..e2fc81acad4 100644 --- a/tests/Configuration/OnlyRuleResolverTest.php +++ b/tests/Configuration/OnlyRuleResolverTest.php @@ -4,6 +4,7 @@ namespace Rector\Tests\Configuration; +use PHPStan\Reflection\ReflectionProvider; use Rector\Configuration\OnlyRuleResolver; use Rector\Contract\Rector\RectorInterface; use Rector\DeadCode\Rector\Assign\RemoveDoubleAssignRector; @@ -11,6 +12,7 @@ use Rector\Exception\Configuration\RectorRuleNameAmbiguousException; use Rector\Exception\Configuration\RectorRuleNotFoundException; use Rector\Testing\PHPUnit\AbstractLazyTestCase; +use Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector; final class OnlyRuleResolverTest extends AbstractLazyTestCase { @@ -23,9 +25,10 @@ protected function setUp(): void $this->bootFromConfigFiles([__DIR__ . '/config/only_rule_resolver_config.php']); $rectorConfig = self::getContainer(); - $this->onlyRuleResolver = new OnlyRuleResolver(iterator_to_array( - $rectorConfig->tagged(RectorInterface::class) - )); + $this->onlyRuleResolver = new OnlyRuleResolver( + iterator_to_array($rectorConfig->tagged(RectorInterface::class)), + $this->make(ReflectionProvider::class) + ); } public function testResolveOk(): void @@ -109,6 +112,28 @@ public function testResolveShortOkTwoLevels(): void ); } + public function testResolveExistingButNotRegistered(): void + { + $this->expectExceptionMessageIsOrContains( + 'Rule "Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector" exists, but is not registered in your Rector config.' + . PHP_EOL . 'Register it in your rector.php:' . PHP_EOL . PHP_EOL + . ' ->withRules([SafeDeclareStrictTypesRector::class])' + ); + $this->expectException(RectorRuleNotFoundException::class); + + $this->onlyRuleResolver->resolve(SafeDeclareStrictTypesRector::class); + } + + public function testResolveShortExistingButNotRegistered(): void + { + $this->expectExceptionMessageIsOrContains( + 'Rule "Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector" exists, but is not registered in your Rector config.' + ); + $this->expectException(RectorRuleNotFoundException::class); + + $this->onlyRuleResolver->resolve('SafeDeclareStrictTypesRector'); + } + public function testResolveShortAmbiguous(): void { $this->expectExceptionMessageIsOrContains( From a3ca6bb51f70acf7f16d06de9134c37ba97657e2 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Thu, 6 Aug 2026 00:24:25 +0200 Subject: [PATCH 2/2] [Configuration] Check only FQN rule class for unregistered rule hint Drop the PSR-4 filesystem scan for short rule names; resolve only fully qualified class names via ReflectionProvider. --- src/Configuration/OnlyRuleResolver.php | 121 ++----------------- tests/Configuration/OnlyRuleResolverTest.php | 10 -- 2 files changed, 13 insertions(+), 118 deletions(-) diff --git a/src/Configuration/OnlyRuleResolver.php b/src/Configuration/OnlyRuleResolver.php index 19f444b6385..1232506f083 100644 --- a/src/Configuration/OnlyRuleResolver.php +++ b/src/Configuration/OnlyRuleResolver.php @@ -4,13 +4,10 @@ namespace Rector\Configuration; -use Composer\Autoload\ClassLoader; use PHPStan\Reflection\ReflectionProvider; use Rector\Contract\Rector\RectorInterface; use Rector\Exception\Configuration\RectorRuleNameAmbiguousException; use Rector\Exception\Configuration\RectorRuleNotFoundException; -use Symfony\Component\Finder\Finder; -use Symfony\Component\Finder\SplFileInfo; /** * @see \Rector\Tests\Configuration\OnlyRuleResolverTest @@ -67,12 +64,6 @@ public function resolve(string $rule): string throw new RectorRuleNameAmbiguousException($message); } - // the rule class exists, it is just missing in the config - $unregisteredRuleClasses = $this->matchUnregisteredRuleClasses($rule); - if ($unregisteredRuleClasses !== []) { - throw new RectorRuleNotFoundException($this->createUnregisteredMessage($rule, $unregisteredRuleClasses)); - } - if (! str_contains($rule, '\\')) { // the shell has eaten unescaped backslashes, e.g. --only=\Rector\Some\Rule $flattenMatching = []; @@ -93,6 +84,11 @@ public function resolve(string $rule): string PHP_EOL ); } else { + // the rule class exists, it is just missing in the config + if ($this->isRectorRuleClass($rule)) { + throw new RectorRuleNotFoundException($this->createUnregisteredMessage($rule)); + } + $message = sprintf( 'Rule "%s" was not found.%sMake sure it is registered in your config or in one of the sets', $rule, @@ -104,44 +100,8 @@ public function resolve(string $rule): string } /** - * Rule classes that exist in the autoloaded code, but are not registered in the config - * - * @return string[] + * Is this an existing rule class, that is just not registered in the config? */ - private function matchUnregisteredRuleClasses(string $rule): array - { - if (str_contains($rule, '\\')) { - return $this->isRectorRuleClass($rule) ? [$rule] : []; - } - - $ruleClasses = []; - - foreach ($this->resolvePsr4Prefixes() as $namespacePrefix => $directories) { - foreach ($directories as $directory) { - if (! is_dir($directory)) { - continue; - } - - $finder = Finder::create() - ->files() - ->in($directory) - ->name($rule . '.php'); - - foreach ($finder as $fileInfo) { - $ruleClass = $this->createClassName($namespacePrefix, $directory, $fileInfo); - if ($this->isRectorRuleClass($ruleClass)) { - $ruleClasses[] = $ruleClass; - } - } - } - } - - $ruleClasses = array_unique($ruleClasses); - sort($ruleClasses); - - return $ruleClasses; - } - private function isRectorRuleClass(string $className): bool { if (! $this->reflectionProvider->hasClass($className)) { @@ -156,71 +116,16 @@ private function isRectorRuleClass(string $className): bool return $classReflection->implementsInterface(RectorInterface::class); } - /** - * Only Rector rule namespaces are worth scanning, as rules always live there - * - * @return array - */ - private function resolvePsr4Prefixes(): array + private function createUnregisteredMessage(string $ruleClass): string { - $psr4Prefixes = []; - - foreach (spl_autoload_functions() as $autoloadFunction) { - if (! is_array($autoloadFunction)) { - continue; - } - - $classLoader = $autoloadFunction[0]; - if (! $classLoader instanceof ClassLoader) { - continue; - } - - foreach ($classLoader->getPrefixesPsr4() as $namespacePrefix => $directories) { - if (! str_contains($namespacePrefix, 'Rector')) { - continue; - } - - $psr4Prefixes[$namespacePrefix] = array_merge( - $psr4Prefixes[$namespacePrefix] ?? [], - array_values($directories) - ); - } - } - - return $psr4Prefixes; - } - - private function createClassName(string $namespacePrefix, string $directory, SplFileInfo $fileInfo): string - { - $relativeDirectory = trim(str_replace($directory, '', $fileInfo->getPath()), '/'); - $namespace = $namespacePrefix . str_replace('/', '\\', $relativeDirectory); - - return rtrim($namespace, '\\') . '\\' . $fileInfo->getFilenameWithoutExtension(); - } - - /** - * @param string[] $unregisteredRuleClasses - */ - private function createUnregisteredMessage(string $rule, array $unregisteredRuleClasses): string - { - if (count($unregisteredRuleClasses) === 1) { - $unregisteredRuleClass = $unregisteredRuleClasses[0]; - $shortRuleClass = substr((string) strrchr($unregisteredRuleClass, '\\'), 1); - - return sprintf( - 'Rule "%s" exists, but is not registered in your Rector config.%sRegister it in your rector.php:' - . PHP_EOL . PHP_EOL . ' ->withRules([%s::class])', - $unregisteredRuleClass, - PHP_EOL, - $shortRuleClass - ); - } + $shortRuleClass = substr((string) strrchr($ruleClass, '\\'), 1); return sprintf( - 'Rule "%s" exists in these classes, but none of them is registered in your Rector config:' . PHP_EOL - . '- ' . implode(PHP_EOL . '- ', $unregisteredRuleClasses) . PHP_EOL - . 'Register one of them in your rector.php', - $rule + 'Rule "%s" exists, but is not registered in your Rector config.%sRegister it in your rector.php:' + . PHP_EOL . PHP_EOL . ' ->withRules([%s::class])', + $ruleClass, + PHP_EOL, + $shortRuleClass ); } } diff --git a/tests/Configuration/OnlyRuleResolverTest.php b/tests/Configuration/OnlyRuleResolverTest.php index e2fc81acad4..e2fdcedea04 100644 --- a/tests/Configuration/OnlyRuleResolverTest.php +++ b/tests/Configuration/OnlyRuleResolverTest.php @@ -124,16 +124,6 @@ public function testResolveExistingButNotRegistered(): void $this->onlyRuleResolver->resolve(SafeDeclareStrictTypesRector::class); } - public function testResolveShortExistingButNotRegistered(): void - { - $this->expectExceptionMessageIsOrContains( - 'Rule "Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector" exists, but is not registered in your Rector config.' - ); - $this->expectException(RectorRuleNotFoundException::class); - - $this->onlyRuleResolver->resolve('SafeDeclareStrictTypesRector'); - } - public function testResolveShortAmbiguous(): void { $this->expectExceptionMessageIsOrContains(