diff --git a/phpstan.neon b/phpstan.neon index fa25c0a2eb2..701ec1fe665 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,6 +5,7 @@ includes: rules: - Rector\Utils\PHPStan\Rule\RegisterRelatedPolyfillRectorRule - Rector\Utils\PHPStan\Rule\NoResolveClassReflectionInForeachRule + - Rector\Utils\PHPStan\Rule\RegisterRectorRule parameters: level: 8 @@ -228,6 +229,19 @@ parameters: - rules/Php81/Rector/FuncCall/NullToStrictStringFuncCallArgRector.php - rules/Php80/Rector/NotIdentical/MbStrContainsRector.php + # not registered in a rector-src set on purpose: NullToStrictString is opt-in only (risky), + # the rest are registered in third-party sets (rector-laravel, drupal-rector) that cannot be + # installed here due to the rector/rector replace + - + identifier: rector.registerRector + paths: + - rules/Php81/Rector/FuncCall/NullToStrictStringFuncCallArgRector.php + - rules/Removing/Rector/Class_/RemoveTraitUseRector.php + - rules/Renaming/Rector/StaticCall/RenameStaticMethodRector.php + - rules/Transform/Rector/FuncCall/FuncCallToNewRector.php + - rules/Transform/Rector/FuncCall/FuncCallToStaticCallRector.php + - rules/Transform/Rector/String_/StringToClassConstantRector.php + # lack of generic array in nikic/php-parser - identifier: return.type diff --git a/utils/phpstan/src/Rule/RegisterRectorRule.php b/utils/phpstan/src/Rule/RegisterRectorRule.php new file mode 100644 index 00000000000..10a68e73582 --- /dev/null +++ b/utils/phpstan/src/Rule/RegisterRectorRule.php @@ -0,0 +1,152 @@ + + * @see \Rector\Utils\PHPStan\Tests\Rule\RegisterRectorRule\RegisterRectorRuleTest + */ +final class RegisterRectorRule implements Rule +{ + private const string ERROR_MESSAGE = 'Rector rule "%s" is not registered in any set or level config, so it can never run. Register it in a set/level, or deprecate it if it is no longer wanted.'; + + /** + * @var string[] + */ + private readonly array $registrationDirectories; + + /** + * @var array|null + */ + private ?array $registeredShortClassNames = null; + + /** + * @param string[]|null $registrationDirectories + */ + public function __construct(?array $registrationDirectories = null) + { + $root = __DIR__ . '/../../../../'; + + $this->registrationDirectories = $registrationDirectories ?? array_merge( + [$root . 'config', $root . 'src/Config'], + // sibling extension packages register rules in their own config sets + glob($root . 'vendor/rector/*/config') ?: [] + ); + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * @param InClassNode $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if ($classReflection->isAbstract()) { + return []; + } + + if (! $classReflection->implementsInterface(RectorInterface::class)) { + return []; + } + + // test doubles are wired directly in their test, not through a set + if (str_starts_with($classReflection->getName(), 'Rector\\Tests\\')) { + return []; + } + + // intentionally unregistered + if ($classReflection->implementsInterface(DeprecatedInterface::class)) { + return []; + } + + $className = $classReflection->getName(); + if ($this->isRegistered($className)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $className)) + ->identifier('rector.registerRector') + ->build(), + ]; + } + + private function isRegistered(string $className): bool + { + $shortClassName = substr((string) strrchr($className, '\\'), 1); + + // short class names are unique repo-wide (enforced by the "no duplicate short class names" check) + return isset($this->getRegisteredShortClassNames()[$shortClassName]); + } + + /** + * Collect every "SomeRector::class" short name referenced in the scanned config directories once. + * The capture group stops at the preceding "\", so both "SomeRector::class" and a fully qualified + * "\Some\Ns\SomeRector::class" yield the same short name. + * + * @return array + */ + private function getRegisteredShortClassNames(): array + { + if ($this->registeredShortClassNames !== null) { + return $this->registeredShortClassNames; + } + + $registeredShortClassNames = []; + foreach ($this->registrationDirectories as $registrationDirectory) { + if (! is_dir($registrationDirectory)) { + continue; + } + + $recursiveIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($registrationDirectory, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $fileInfo */ + foreach ($recursiveIterator as $fileInfo) { + if ($fileInfo->getExtension() !== 'php') { + continue; + } + + $fileContents = file_get_contents($fileInfo->getRealPath()); + if ($fileContents === false) { + continue; + } + + preg_match_all('#([A-Za-z_][A-Za-z0-9_]*)::class#', $fileContents, $matches); + foreach ($matches[1] as $shortClassName) { + $registeredShortClassNames[$shortClassName] = true; + } + } + } + + $this->registeredShortClassNames = $registeredShortClassNames; + + return $registeredShortClassNames; + } +} diff --git a/utils/phpstan/tests/Rule/RegisterRectorRule/RegisterRectorRuleTest.php b/utils/phpstan/tests/Rule/RegisterRectorRule/RegisterRectorRuleTest.php new file mode 100644 index 00000000000..e3658c42b79 --- /dev/null +++ b/utils/phpstan/tests/Rule/RegisterRectorRule/RegisterRectorRuleTest.php @@ -0,0 +1,49 @@ + + */ +final class RegisterRectorRuleTest extends RuleTestCase +{ + public function testUnregistered(): void + { + $expectedErrorMessage = 'Rector rule "Rector\Utils\PHPStan\Tests\Rule\RegisterRectorRule\Source\UnregisteredRector" is not registered in any set or level config, so it can never run. Register it in a set/level, or deprecate it if it is no longer wanted.'; + + $this->analyse([__DIR__ . '/Source/UnregisteredRector.php'], [[$expectedErrorMessage, 12]]); + } + + public function testUnregisteredConfigurable(): void + { + $expectedErrorMessage = 'Rector rule "Rector\Utils\PHPStan\Tests\Rule\RegisterRectorRule\Source\UnregisteredConfigurableRector" is not registered in any set or level config, so it can never run. Register it in a set/level, or deprecate it if it is no longer wanted.'; + + $this->analyse([__DIR__ . '/Source/UnregisteredConfigurableRector.php'], [[$expectedErrorMessage, 13]]); + } + + public function testRegistered(): void + { + $this->analyse([__DIR__ . '/Source/RegisteredRector.php'], []); + } + + public function testRegisteredInSiblingConfig(): void + { + $this->analyse([__DIR__ . '/Source/SiblingRegisteredRector.php'], []); + } + + public function testSkipDeprecated(): void + { + $this->analyse([__DIR__ . '/Source/SkipDeprecatedRector.php'], []); + } + + protected function getRule(): Rule + { + return new RegisterRectorRule([__DIR__ . '/config', __DIR__ . '/config-sibling']); + } +} diff --git a/utils/phpstan/tests/Rule/RegisterRectorRule/Source/RegisteredRector.php b/utils/phpstan/tests/Rule/RegisterRectorRule/Source/RegisteredRector.php new file mode 100644 index 00000000000..f34c1e6e5f6 --- /dev/null +++ b/utils/phpstan/tests/Rule/RegisterRectorRule/Source/RegisteredRector.php @@ -0,0 +1,28 @@ +