From 7af05f0f19bb262a0b8836fc6acab3a3ab72c410 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:01:01 +0200 Subject: [PATCH 1/4] [Console] Add validate-config command for config hygiene checks --- src/Console/Command/ValidateConfigCommand.php | 101 ++++++++++++++++++ .../LazyContainerFactory.php | 2 + src/Reporting/DeprecatedRulesReporter.php | 44 ++++++-- src/Reporting/MissConfigurationReporter.php | 12 ++- .../Command/Source/DeprecatedFixtureRule.php | 11 ++ .../Command/ValidateConfigCommandTest.php | 66 ++++++++++++ 6 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 src/Console/Command/ValidateConfigCommand.php create mode 100644 tests/Console/Command/Source/DeprecatedFixtureRule.php create mode 100644 tests/Console/Command/ValidateConfigCommandTest.php diff --git a/src/Console/Command/ValidateConfigCommand.php b/src/Console/Command/ValidateConfigCommand.php new file mode 100644 index 00000000000..2e421f43c44 --- /dev/null +++ b/src/Console/Command/ValidateConfigCommand.php @@ -0,0 +1,101 @@ +setName('validate-config'); + $this->setDescription('Report config hygiene issues without processing any files'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $issueCount = 0; + + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedRules(); + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedSkippedRules(); + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedCacheMetaExtensions(); + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedPhpSetsMethods(); + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedAttributesSetsArgs(); + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedComposerBasedArgs(); + $issueCount += $this->deprecatedRulesReporter->reportDeprecatedRectorUnsupportedMethods(); + + $issueCount += $this->missConfigurationReporter->reportSkippedNeverRegisteredRules(); + $issueCount += $this->missConfigurationReporter->reportSkippedNonRectorClasses(); + + $issueCount += $this->reportDeprecatedSkippedClasses(); + $issueCount += $this->reportSetAndRulesDuplicatedRegistrations(); + + if ($issueCount === 0) { + $this->symfonyStyle->success('Config is valid, no issues found'); + return ExitCode::SUCCESS; + } + + $this->symfonyStyle->error(sprintf( + '%d config %s found, see the warnings above', + $issueCount, + $issueCount === 1 ? 'issue' : 'issues' + )); + + return ExitCode::FAILURE; + } + + private function reportDeprecatedSkippedClasses(): int + { + $deprecatedSkippedClasses = $this->skippedClassResolver->resolveDeprecatedSkippedClasses(); + if ($deprecatedSkippedClasses === []) { + return 0; + } + + $this->symfonyStyle->warning(sprintf( + 'These rules are skipped, but are deprecated. Most likely you do not need to skip them anymore, remove them: %s%s', + "\n\n", + '* ' . implode("\n* ", $deprecatedSkippedClasses) . "\n" + )); + + return count($deprecatedSkippedClasses); + } + + private function reportSetAndRulesDuplicatedRegistrations(): int + { + $setAndRulesDuplicatedRegistrations = new Configuration() + ->getBothSetAndRulesDuplicatedRegistrations(); + if ($setAndRulesDuplicatedRegistrations === []) { + return 0; + } + + $this->symfonyStyle->warning(sprintf( + 'These rules are registered in both sets and "withRules()". Remove them from "withRules()" to avoid duplications: %s* %s', + "\n\n", + implode(' * ', $setAndRulesDuplicatedRegistrations) . "\n" + )); + + return count($setAndRulesDuplicatedRegistrations); + } +} diff --git a/src/DependencyInjection/LazyContainerFactory.php b/src/DependencyInjection/LazyContainerFactory.php index 7c60bb2a9ae..33d6146a522 100644 --- a/src/DependencyInjection/LazyContainerFactory.php +++ b/src/DependencyInjection/LazyContainerFactory.php @@ -37,6 +37,7 @@ use Rector\Console\Command\ListRulesCommand; use Rector\Console\Command\ProcessCommand; use Rector\Console\Command\SetupCICommand; +use Rector\Console\Command\ValidateConfigCommand; use Rector\Console\Command\WorkerCommand; use Rector\Console\ConsoleApplication; use Rector\Console\Style\SymfonyStyleFactory; @@ -158,6 +159,7 @@ private function registerConsole(RectorConfig $rectorConfig): void $rectorConfig->singleton(ProcessCommand::class); $rectorConfig->singleton(WorkerCommand::class); + $rectorConfig->singleton(ValidateConfigCommand::class); $rectorConfig->singleton(SetupCICommand::class); $rectorConfig->singleton(ListRulesCommand::class); $rectorConfig->singleton(CustomRuleCommand::class); diff --git a/src/Reporting/DeprecatedRulesReporter.php b/src/Reporting/DeprecatedRulesReporter.php index 97dc68d929d..b0c579a77b2 100644 --- a/src/Reporting/DeprecatedRulesReporter.php +++ b/src/Reporting/DeprecatedRulesReporter.php @@ -23,11 +23,12 @@ public function __construct( ) { } - public function reportDeprecatedRules(): void + public function reportDeprecatedRules(): int { /** @var string[] $registeredRectorRules */ $registeredRectorRules = SimpleParameterProvider::provideArrayParameter(Option::REGISTERED_RECTOR_RULES); + $reportedCount = 0; foreach ($registeredRectorRules as $registeredRectorRule) { if (! is_a($registeredRectorRule, DeprecatedInterface::class, true)) { continue; @@ -39,88 +40,112 @@ public function reportDeprecatedRules(): void $registeredRectorRule ) ); + ++$reportedCount; } + + return $reportedCount; } - public function reportDeprecatedSkippedRules(): void + public function reportDeprecatedSkippedRules(): int { /** @var string[] $skippedRectorRules */ $skippedRectorRules = SimpleParameterProvider::provideArrayParameter(Option::SKIPPED_RECTOR_RULES); + $reportedCount = 0; foreach ($skippedRectorRules as $skippedRectorRule) { if (! is_a($skippedRectorRule, DeprecatedInterface::class, true)) { continue; } $this->symfonyStyle->warning(sprintf('Skipped rule "%s" is deprecated', $skippedRectorRule)); + ++$reportedCount; } + + return $reportedCount; } - public function reportDeprecatedCacheMetaExtensions(): void + public function reportDeprecatedCacheMetaExtensions(): int { /** @var string[] $cacheMetaExtensions */ $cacheMetaExtensions = SimpleParameterProvider::provideArrayParameter(Option::CACHE_META_EXTENSIONS); + $reportedCount = 0; foreach ($cacheMetaExtensions as $cacheMetumExtension) { $this->symfonyStyle->warning(sprintf( 'Cache meta extension "%s" is deprecated and no longer applied. It is a niche mechanism, let Rector handle cache on its own. If custom invalidation is needed, handle it in CI in a more generic way, e.g. by clearing the cache directory.', $cacheMetumExtension )); + ++$reportedCount; } + + return $reportedCount; } - public function reportDeprecatedPhpSetsMethods(): void + public function reportDeprecatedPhpSetsMethods(): int { /** @var string[] $deprecatedPhpSetsMethods */ $deprecatedPhpSetsMethods = SimpleParameterProvider::provideArrayParameter( Option::DEPRECATED_PHP_SETS_METHODS ); + $reportedCount = 0; foreach (array_unique($deprecatedPhpSetsMethods) as $deprecatedPhpSetsMethod) { $this->symfonyStyle->warning(sprintf( 'The "->%s()" method is deprecated and no longer applied. Use "->withPhpLevel()" instead, to raise PHP level one rule at a time.', $deprecatedPhpSetsMethod )); + ++$reportedCount; } + + return $reportedCount; } - public function reportDeprecatedAttributesSetsArgs(): void + public function reportDeprecatedAttributesSetsArgs(): int { /** @var string[] $deprecatedAttributesSetsArgs */ $deprecatedAttributesSetsArgs = SimpleParameterProvider::provideArrayParameter( Option::DEPRECATED_ATTRIBUTES_SETS_ARGS ); + $reportedCount = 0; foreach (array_unique($deprecatedAttributesSetsArgs) as $deprecatedAttributesSetsArg) { $this->symfonyStyle->warning(sprintf( 'The "->withAttributesSets(%s: true)" argument is deprecated and no longer applied. It is already included in the "symfony: true" argument, use it instead.', $deprecatedAttributesSetsArg )); + ++$reportedCount; } + + return $reportedCount; } - public function reportDeprecatedComposerBasedArgs(): void + public function reportDeprecatedComposerBasedArgs(): int { /** @var string[] $deprecatedComposerBasedArgs */ $deprecatedComposerBasedArgs = SimpleParameterProvider::provideArrayParameter( Option::DEPRECATED_COMPOSER_BASED_ARGS ); + $reportedCount = 0; foreach (array_unique($deprecatedComposerBasedArgs) as $deprecatedComposerBasedArg) { $this->symfonyStyle->warning(sprintf( 'The "->withComposerBased(%s: true)" argument is deprecated and no longer applied. It only added named args to 2 methods of a single package, register the rule directly if needed.', $deprecatedComposerBasedArg )); + ++$reportedCount; } + + return $reportedCount; } - public function reportDeprecatedRectorUnsupportedMethods(): void + public function reportDeprecatedRectorUnsupportedMethods(): int { // to be added in related PR if (! class_exists(FileNode::class)) { - return; + return 0; } + $reportedCount = 0; foreach ($this->rectors as $rector) { $beforeTraverseMethodReflection = new ReflectionMethod($rector, 'beforeTraverse'); if ($beforeTraverseMethodReflection->getDeclaringClass()->getName() === $rector::class) { @@ -129,7 +154,10 @@ public function reportDeprecatedRectorUnsupportedMethods(): void $rector::class, FileNode::class )); + ++$reportedCount; } } + + return $reportedCount; } } diff --git a/src/Reporting/MissConfigurationReporter.php b/src/Reporting/MissConfigurationReporter.php index 4a3b91f433a..3a137588b65 100644 --- a/src/Reporting/MissConfigurationReporter.php +++ b/src/Reporting/MissConfigurationReporter.php @@ -47,7 +47,7 @@ public function reportUnusedSkips(ProcessResult $processResult): void $this->symfonyStyle->listing($spacedUnusedSkips); } - public function reportSkippedNeverRegisteredRules(): void + public function reportSkippedNeverRegisteredRules(): int { $registeredRules = SimpleParameterProvider::provideArrayParameter(Option::REGISTERED_RECTOR_RULES); $skippedRules = SimpleParameterProvider::provideArrayParameter(Option::SKIPPED_RECTOR_RULES); @@ -61,7 +61,7 @@ public function reportSkippedNeverRegisteredRules(): void ); if ($neverRegisteredSkippedRules === []) { - return; + return 0; } $this->symfonyStyle->warning(sprintf( @@ -71,16 +71,18 @@ public function reportSkippedNeverRegisteredRules(): void )); $this->symfonyStyle->listing($neverRegisteredSkippedRules); + + return count($neverRegisteredSkippedRules); } - public function reportSkippedNonRectorClasses(): void + public function reportSkippedNonRectorClasses(): int { $skippedNonRectorClasses = SimpleParameterProvider::provideArrayParameter( Option::SKIPPED_NON_RECTOR_CLASSES ); if ($skippedNonRectorClasses === []) { - return; + return 0; } $this->symfonyStyle->warning(sprintf( @@ -91,6 +93,8 @@ public function reportSkippedNonRectorClasses(): void count($skippedNonRectorClasses) > 1 ? 'they can' : 'it can', RectorInterface::class )); + + return count($skippedNonRectorClasses); } /** diff --git a/tests/Console/Command/Source/DeprecatedFixtureRule.php b/tests/Console/Command/Source/DeprecatedFixtureRule.php new file mode 100644 index 00000000000..4029fbc22f4 --- /dev/null +++ b/tests/Console/Command/Source/DeprecatedFixtureRule.php @@ -0,0 +1,11 @@ +make(UnusedSkipResolver::class), + ), + $this->make(SkippedClassResolver::class), + ); + + $this->commandTester = new CommandTester($validateConfigCommand); + } + + protected function tearDown(): void + { + SimpleParameterProvider::setParameter(Option::REGISTERED_RECTOR_RULES, []); + } + + public function testFailsOnDeprecatedRegisteredRule(): void + { + SimpleParameterProvider::setParameter(Option::REGISTERED_RECTOR_RULES, [DeprecatedFixtureRule::class]); + + $this->assertSame(ExitCode::FAILURE, $this->commandTester->execute([])); + } + + public function testSucceedsOnCleanConfig(): void + { + SimpleParameterProvider::setParameter(Option::REGISTERED_RECTOR_RULES, [OrdSingleByteRector::class]); + + $this->assertSame(ExitCode::SUCCESS, $this->commandTester->execute([])); + } +} From 27fb9b4908e25179458e21eb59ee6a341dea6620 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:47:25 +0200 Subject: [PATCH 2/4] [Console] Add --output-format=json to validate-config (#8452) --- src/Console/Command/ValidateConfigCommand.php | 27 ++++++++++++++ .../Command/ValidateConfigCommandTest.php | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/Console/Command/ValidateConfigCommand.php b/src/Console/Command/ValidateConfigCommand.php index 2e421f43c44..7e193f92ae0 100644 --- a/src/Console/Command/ValidateConfigCommand.php +++ b/src/Console/Command/ValidateConfigCommand.php @@ -4,6 +4,10 @@ namespace Rector\Console\Command; +use Nette\Utils\Json; +use Rector\ChangesReporting\Output\ConsoleOutputFormatter; +use Rector\ChangesReporting\Output\JsonOutputFormatter; +use Rector\Configuration\Option; use Rector\Console\ExitCode; use Rector\Reporting\DeprecatedRulesReporter; use Rector\Reporting\MissConfigurationReporter; @@ -11,6 +15,7 @@ use Rector\ValueObject\Configuration; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -32,10 +37,24 @@ protected function configure(): void { $this->setName('validate-config'); $this->setDescription('Report config hygiene issues without processing any files'); + $this->addOption( + Option::OUTPUT_FORMAT, + null, + InputOption::VALUE_REQUIRED, + sprintf('Output format: "%s" or "%s"', ConsoleOutputFormatter::NAME, JsonOutputFormatter::NAME), + ConsoleOutputFormatter::NAME + ); } protected function execute(InputInterface $input, OutputInterface $output): int { + $isJsonOutput = $input->getOption(Option::OUTPUT_FORMAT) === JsonOutputFormatter::NAME; + + // silence the human-readable warnings, so only the JSON payload lands on stdout + if ($isJsonOutput) { + $this->symfonyStyle->setVerbosity(OutputInterface::VERBOSITY_QUIET); + } + $issueCount = 0; $issueCount += $this->deprecatedRulesReporter->reportDeprecatedRules(); @@ -52,6 +71,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $issueCount += $this->reportDeprecatedSkippedClasses(); $issueCount += $this->reportSetAndRulesDuplicatedRegistrations(); + if ($isJsonOutput) { + echo Json::encode([ + 'valid' => $issueCount === 0, + 'issue_count' => $issueCount, + ], pretty: true) . PHP_EOL; + return $issueCount === 0 ? ExitCode::SUCCESS : ExitCode::FAILURE; + } + if ($issueCount === 0) { $this->symfonyStyle->success('Config is valid, no issues found'); return ExitCode::SUCCESS; diff --git a/tests/Console/Command/ValidateConfigCommandTest.php b/tests/Console/Command/ValidateConfigCommandTest.php index b630287ecf5..5aba86ff3fc 100644 --- a/tests/Console/Command/ValidateConfigCommandTest.php +++ b/tests/Console/Command/ValidateConfigCommandTest.php @@ -4,6 +4,7 @@ namespace Rector\Tests\Console\Command; +use Nette\Utils\Json; use Rector\Configuration\Option; use Rector\Configuration\Parameter\SimpleParameterProvider; use Rector\Configuration\VendorMissAnalyseGuard; @@ -63,4 +64,38 @@ public function testSucceedsOnCleanConfig(): void $this->assertSame(ExitCode::SUCCESS, $this->commandTester->execute([])); } + + public function testJsonOutputOnCleanConfig(): void + { + SimpleParameterProvider::setParameter(Option::REGISTERED_RECTOR_RULES, [OrdSingleByteRector::class]); + + ob_start(); + $exitCode = $this->commandTester->execute([ + '--output-format' => 'json', + ]); + $json = (string) ob_get_clean(); + + $this->assertSame(ExitCode::SUCCESS, $exitCode); + $this->assertSame([ + 'valid' => true, + 'issue_count' => 0, + ], Json::decode($json, forceArrays: true)); + } + + public function testJsonOutputOnDeprecatedRegisteredRule(): void + { + SimpleParameterProvider::setParameter(Option::REGISTERED_RECTOR_RULES, [DeprecatedFixtureRule::class]); + + ob_start(); + $exitCode = $this->commandTester->execute([ + '--output-format' => 'json', + ]); + $json = (string) ob_get_clean(); + + $this->assertSame(ExitCode::FAILURE, $exitCode); + $this->assertSame([ + 'valid' => false, + 'issue_count' => 1, + ], Json::decode($json, forceArrays: true)); + } } From 5a2c925233853061fb1de2ba74c9fbd0ebef9e9b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:51:01 +0200 Subject: [PATCH 3/4] [Console] Reset leaked config params in ValidateConfigCommandTest setUp Fixes shared-process test isolation: other tests leak ROOT_STANDALONE_REGISTERED_RULES / SET_REGISTERED_RULES into the global param provider, which made getBothSetAndRulesDuplicatedRegistrations report a false issue. Claude-Session: https://claude.ai/code/session_01QDEtmow9psVbccgbgHDqgd --- tests/Console/Command/ValidateConfigCommandTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Console/Command/ValidateConfigCommandTest.php b/tests/Console/Command/ValidateConfigCommandTest.php index 5aba86ff3fc..2277190cf55 100644 --- a/tests/Console/Command/ValidateConfigCommandTest.php +++ b/tests/Console/Command/ValidateConfigCommandTest.php @@ -30,6 +30,12 @@ protected function setUp(): void { parent::setUp(); + // clean baseline, so params leaked by other tests in the shared process do not count as issues + SimpleParameterProvider::setParameter(Option::SKIP, []); + SimpleParameterProvider::setParameter(Option::SKIPPED_NON_RECTOR_CLASSES, []); + SimpleParameterProvider::setParameter(Option::ROOT_STANDALONE_REGISTERED_RULES, []); + SimpleParameterProvider::setParameter(Option::SET_REGISTERED_RULES, []); + $symfonyStyle = new SymfonyStyle(new ArrayInput([]), new BufferedOutput()); $validateConfigCommand = new ValidateConfigCommand( From 5adc445ea631383f129897772bf73bd1c7d59dff Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:56:32 +0200 Subject: [PATCH 4/4] [Console] Reset all reporter-read config params in ValidateConfigCommandTest setUp Extend the clean baseline to every global param the hygiene reporters read, so no chunk order in the shared test process leaks a false issue into the JSON deprecated-rule count. Claude-Session: https://claude.ai/code/session_01QDEtmow9psVbccgbgHDqgd --- .../Command/ValidateConfigCommandTest.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/Console/Command/ValidateConfigCommandTest.php b/tests/Console/Command/ValidateConfigCommandTest.php index 2277190cf55..ffb33346211 100644 --- a/tests/Console/Command/ValidateConfigCommandTest.php +++ b/tests/Console/Command/ValidateConfigCommandTest.php @@ -31,10 +31,20 @@ protected function setUp(): void parent::setUp(); // clean baseline, so params leaked by other tests in the shared process do not count as issues - SimpleParameterProvider::setParameter(Option::SKIP, []); - SimpleParameterProvider::setParameter(Option::SKIPPED_NON_RECTOR_CLASSES, []); - SimpleParameterProvider::setParameter(Option::ROOT_STANDALONE_REGISTERED_RULES, []); - SimpleParameterProvider::setParameter(Option::SET_REGISTERED_RULES, []); + foreach ([ + Option::SKIP, + Option::SKIPPED_NON_RECTOR_CLASSES, + Option::SKIPPED_RECTOR_RULES, + Option::SKIPPED_START_WITH_SHORT_OPEN_TAG_FILES, + Option::CACHE_META_EXTENSIONS, + Option::DEPRECATED_PHP_SETS_METHODS, + Option::DEPRECATED_ATTRIBUTES_SETS_ARGS, + Option::DEPRECATED_COMPOSER_BASED_ARGS, + Option::ROOT_STANDALONE_REGISTERED_RULES, + Option::SET_REGISTERED_RULES, + ] as $optionName) { + SimpleParameterProvider::setParameter($optionName, []); + } $symfonyStyle = new SymfonyStyle(new ArrayInput([]), new BufferedOutput());