From 12b3c328f0760ae93e6ca98c938d629fd3ac8995 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:24:15 +0200 Subject: [PATCH 1/2] [Console] Allow --only to run several rules at once --- src/Application/ApplicationFileProcessor.php | 4 ++-- src/Caching/Detector/ChangedFilesDetector.php | 7 ++++--- src/Configuration/ConfigurationFactory.php | 14 ++++++++------ src/Configuration/ConfigurationRuleFilter.php | 16 ++++++++++------ src/Console/ProcessConfigureDecorator.php | 7 ++++++- .../Command/WorkerCommandLineFactory.php | 17 +++++++++++------ src/ValueObject/Configuration.php | 10 +++++++--- .../ApplicationFileProcessorTest.php | 10 +++++----- .../Detector/ChangedFilesDetectorTest.php | 8 ++++---- .../ConfigurationRuleFilterTest.php | 19 +++++++++++++++++++ 10 files changed, 76 insertions(+), 36 deletions(-) diff --git a/src/Application/ApplicationFileProcessor.php b/src/Application/ApplicationFileProcessor.php index 311b23045fa..b7fff79015e 100644 --- a/src/Application/ApplicationFileProcessor.php +++ b/src/Application/ApplicationFileProcessor.php @@ -57,7 +57,7 @@ public function __construct( public function run(Configuration $configuration, InputInterface $input): ProcessResult { // scope the cache to this run's --only / --only-suffix selection before any cache read/write - $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix(), $configuration->getFilters()); + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRules(), $configuration->getOnlySuffix(), $configuration->getFilters()); $filePaths = $this->filesFinder->findFilesInPaths($configuration->getPaths(), $configuration); @@ -125,7 +125,7 @@ public function processFiles( ?callable $postFileCallback = null ): ProcessResult { // also set here: parallel workers reach processFiles() via WorkerCommand, bypassing run() - $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix(), $configuration->getFilters()); + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRules(), $configuration->getOnlySuffix(), $configuration->getFilters()); /** @var SystemError[] $systemErrors */ $systemErrors = []; diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index ebe513d0edd..385dbffb98c 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -33,14 +33,15 @@ public function __construct( } /** + * @param string[] $onlyRules * @param string[] $filters */ - public function setActiveScope(?string $onlyRule, ?string $onlySuffix, array $filters = []): void + public function setActiveScope(array $onlyRules, ?string $onlySuffix, array $filters = []): void { // each selection gets its own cache key, so --only and full runs coexist without clearing or poisoning - $this->scopeSuffix = ($onlyRule === null && $onlySuffix === null && $filters === []) + $this->scopeSuffix = ($onlyRules === [] && $onlySuffix === null && $filters === []) ? '' - : '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? '') . '|filter:' . implode(',', $filters); + : '|only:' . implode(',', $onlyRules) . '|suffix:' . ($onlySuffix ?? '') . '|filter:' . implode(',', $filters); } public function cacheFile(string $filePath): void diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index f0bf20040e0..dff51b7d014 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -67,10 +67,12 @@ public function createFromInput(InputInterface $input): Configuration $fileExtensions = SimpleParameterProvider::provideArrayParameter(Option::FILE_EXTENSIONS); // filter rule and path - $onlyRule = $input->getOption(Option::ONLY); - if ($onlyRule !== null) { - $onlyRule = $this->onlyRuleResolver->resolve($onlyRule); - } + /** @var string[] $onlyRuleInputs */ + $onlyRuleInputs = (array) $input->getOption(Option::ONLY); + $onlyRules = array_map( + $this->onlyRuleResolver->resolve(...), + $onlyRuleInputs + ); $onlySuffix = $input->getOption(Option::ONLY_SUFFIX); if ($onlySuffix !== null) { @@ -84,7 +86,7 @@ public function createFromInput(InputInterface $input): Configuration // "--only"/"--only-suffix"/"--filter" narrow the run, so skips outside the scope look falsely unused; // mark the run as narrowed to disable unused skip reporting and avoid false positives - if ($onlyRule !== null || $onlySuffix !== null || $filters !== []) { + if ($onlyRules !== [] || $onlySuffix !== null || $filters !== []) { SimpleParameterProvider::setParameter(Option::IS_RUN_NARROWED, true); } @@ -141,7 +143,7 @@ public function createFromInput(InputInterface $input): Configuration $memoryLimit, $isDebug, $isReportingWithRealPath, - $onlyRule, + $onlyRules, $onlySuffix, $levelOverflows, $showRulesSummary, diff --git a/src/Configuration/ConfigurationRuleFilter.php b/src/Configuration/ConfigurationRuleFilter.php index 7f9ea2cf13b..798a4fa188b 100644 --- a/src/Configuration/ConfigurationRuleFilter.php +++ b/src/Configuration/ConfigurationRuleFilter.php @@ -41,9 +41,9 @@ public function filter(array $rectors): array return $rectors; } - $onlyRule = $this->configuration->getOnlyRule(); - if ($onlyRule !== null) { - return $this->filterOnlyRule($rectors, $onlyRule); + $onlyRules = $this->configuration->getOnlyRules(); + if ($onlyRules !== []) { + return $this->filterOnlyRules($rectors, $onlyRules); } if ($this->configuration->isComposerBased()) { @@ -59,14 +59,18 @@ public function filter(array $rectors): array /** * @param list $rectors + * @param string[] $onlyRules * @return list */ - public function filterOnlyRule(array $rectors, string $onlyRule): array + public function filterOnlyRules(array $rectors, array $onlyRules): array { $activeRectors = []; foreach ($rectors as $rector) { - if ($rector instanceof $onlyRule) { - $activeRectors[] = $rector; + foreach ($onlyRules as $onlyRule) { + if ($rector instanceof $onlyRule) { + $activeRectors[] = $rector; + break; + } } } diff --git a/src/Console/ProcessConfigureDecorator.php b/src/Console/ProcessConfigureDecorator.php index 5e32a27b168..800168aba89 100644 --- a/src/Console/ProcessConfigureDecorator.php +++ b/src/Console/ProcessConfigureDecorator.php @@ -57,7 +57,12 @@ public static function decorate(Command $command): void ); // filter by rule and path - $command->addOption(Option::ONLY, null, InputOption::VALUE_REQUIRED, 'Fully qualified rule class name'); + $command->addOption( + Option::ONLY, + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Fully qualified rule class name; repeat to run several rules, e.g. --only=A --only=B' + ); $command->addOption( Option::COMPOSER_BASED, diff --git a/src/Parallel/Command/WorkerCommandLineFactory.php b/src/Parallel/Command/WorkerCommandLineFactory.php index 36f44109f2a..87293be531f 100644 --- a/src/Parallel/Command/WorkerCommandLineFactory.php +++ b/src/Parallel/Command/WorkerCommandLineFactory.php @@ -127,11 +127,6 @@ public function create( $workerCommandArray[] = self::OPTION_DASHES . Option::COMPOSER_BASED; } - if ($input->getOption(Option::ONLY) !== null) { - $workerCommandArray[] = self::OPTION_DASHES . Option::ONLY; - $workerCommandArray[] = escapeshellarg((string) $input->getOption(Option::ONLY)); - } - return implode(' ', $workerCommandArray); } @@ -175,7 +170,7 @@ private function mirrorCommandOptions(InputInterface $input, array $mainCommandO continue; } - /** @var bool|string|null $optionValue */ + /** @var bool|string|string[]|null $optionValue */ $optionValue = $input->getOption($mainCommandOptionName); // skip clutter @@ -191,6 +186,16 @@ private function mirrorCommandOptions(InputInterface $input, array $mainCommandO continue; } + // array options (e.g. repeated --only) are mirrored one flag per value + if (is_array($optionValue)) { + foreach ($optionValue as $singleOptionValue) { + $workerCommandOptions[] = self::OPTION_DASHES . $mainCommandOptionName; + $workerCommandOptions[] = \escapeshellarg($singleOptionValue); + } + + continue; + } + if ($mainCommandOptionName === 'memory-limit') { // symfony/console does not accept -1 as value without assign $workerCommandOptions[] = self::OPTION_DASHES . $mainCommandOptionName . '=' . \escapeshellarg( diff --git a/src/ValueObject/Configuration.php b/src/ValueObject/Configuration.php index 3d0f2118b4b..3497baf3722 100644 --- a/src/ValueObject/Configuration.php +++ b/src/ValueObject/Configuration.php @@ -15,6 +15,7 @@ /** * @param string[] $fileExtensions * @param string[] $paths + * @param string[] $onlyRules * @param LevelOverflow[] $levelOverflows * @param string[] $filters */ @@ -32,7 +33,7 @@ public function __construct( private string|null $memoryLimit = null, private bool $isDebug = false, private bool $reportingWithRealPath = false, - private ?string $onlyRule = null, + private array $onlyRules = [], private ?string $onlySuffix = null, private array $levelOverflows = [], private bool $showRulesSummary = false, @@ -82,9 +83,12 @@ public function getFileExtensions(): array return $this->fileExtensions; } - public function getOnlyRule(): ?string + /** + * @return string[] + */ + public function getOnlyRules(): array { - return $this->onlyRule; + return $this->onlyRules; } /** diff --git a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php index c42ad956d1c..9d90a5fdd28 100644 --- a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php +++ b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php @@ -49,15 +49,15 @@ public function testOnlyRuleRunCachesUnderOwnScopeWithoutPoisoningFullRun(): voi $this->applicationFileProcessor->processFiles([$filePath], new Configuration( isDryRun: true, - onlyRule: RemoveEmptyClassMethodRector::class + onlyRules: [RemoveEmptyClassMethodRector::class] )); // a repeated --only run hits its own scoped cache entry - $this->changedFilesDetector->setActiveScope(RemoveEmptyClassMethodRector::class, null); + $this->changedFilesDetector->setActiveScope([RemoveEmptyClassMethodRector::class], null); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); // a full run uses a different scope key, so it is not poisoned - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } @@ -70,10 +70,10 @@ public function testOnlySuffixRunCachesUnderOwnScopeWithoutPoisoningFullRun(): v onlySuffix: 'Controller.php' )); - $this->changedFilesDetector->setActiveScope(null, 'Controller.php'); + $this->changedFilesDetector->setActiveScope([], 'Controller.php'); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } diff --git a/tests/Caching/Detector/ChangedFilesDetectorTest.php b/tests/Caching/Detector/ChangedFilesDetectorTest.php index 3fcdc0d2b40..a904f939c04 100644 --- a/tests/Caching/Detector/ChangedFilesDetectorTest.php +++ b/tests/Caching/Detector/ChangedFilesDetectorTest.php @@ -68,13 +68,13 @@ public function testScopedRunReusesFullRunCache(): void $filePath = __DIR__ . '/Source/file.php'; // full run caches the file as clean - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->changedFilesDetector->addCacheableFile($filePath); $this->changedFilesDetector->cacheFile($filePath); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); // an --only run reuses the full-run cache instead of re-analysing the file - $this->changedFilesDetector->setActiveScope('Rector\\SomeRule', null); + $this->changedFilesDetector->setActiveScope(['Rector\\SomeRule'], null); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); } @@ -83,13 +83,13 @@ public function testScopedCacheDoesNotLeakToFullRun(): void $filePath = __DIR__ . '/Source/file.php'; // a scoped run only caches under its own key - $this->changedFilesDetector->setActiveScope('Rector\\SomeRule', null); + $this->changedFilesDetector->setActiveScope(['Rector\\SomeRule'], null); $this->changedFilesDetector->addCacheableFile($filePath); $this->changedFilesDetector->cacheFile($filePath); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); // a full run must not treat the file as cached, as only one rule ran - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } diff --git a/tests/Configuration/ConfigurationRuleFilterTest.php b/tests/Configuration/ConfigurationRuleFilterTest.php index 0ac159485c6..ea7228a2fd6 100644 --- a/tests/Configuration/ConfigurationRuleFilterTest.php +++ b/tests/Configuration/ConfigurationRuleFilterTest.php @@ -5,6 +5,7 @@ namespace Rector\Tests\Configuration; use Rector\Configuration\ConfigurationRuleFilter; +use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector; use Rector\DeadCode\Rector\If_\RemoveDeadInstanceOfRector; use Rector\Php80\Rector\Class_\StringableForToStringRector; use Rector\Testing\PHPUnit\AbstractLazyTestCase; @@ -64,6 +65,24 @@ public function testFiltersOutDeprecatedRules(): void $this->assertSame([$removeDeadInstanceOfRector], $filteredRectors); } + public function testOnlyRulesKeepsEveryListedRule(): void + { + $stringableForToStringRector = $this->make(StringableForToStringRector::class); + $removeDeadInstanceOfRector = $this->make(RemoveDeadInstanceOfRector::class); + $removeEmptyClassMethodRector = $this->make(RemoveEmptyClassMethodRector::class); + + $this->configurationRuleFilter->setConfiguration(new Configuration(onlyRules: [ + StringableForToStringRector::class, + RemoveEmptyClassMethodRector::class, + ])); + + $filteredRectors = $this->configurationRuleFilter->filter( + [$stringableForToStringRector, $removeDeadInstanceOfRector, $removeEmptyClassMethodRector] + ); + + $this->assertSame([$stringableForToStringRector, $removeEmptyClassMethodRector], $filteredRectors); + } + private function createConfiguration(bool $isPhpOnly): Configuration { return new Configuration(isPhpOnly: $isPhpOnly); From 178c1ad062cafa46264c199d69a70447783fe43d Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 00:52:41 +0200 Subject: [PATCH 2/2] [Console] Reset shared filter config in ConfigurationRuleFilterTest tearDown The new onlyRules test left the shared ConfigurationRuleFilter singleton holding onlyRules, which leaked into other tests in the same process and filtered out their rules. Reset to a neutral Configuration after each test. Claude-Session: https://claude.ai/code/session_01QDEtmow9psVbccgbgHDqgd --- tests/Configuration/ConfigurationRuleFilterTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Configuration/ConfigurationRuleFilterTest.php b/tests/Configuration/ConfigurationRuleFilterTest.php index ea7228a2fd6..de10056c31f 100644 --- a/tests/Configuration/ConfigurationRuleFilterTest.php +++ b/tests/Configuration/ConfigurationRuleFilterTest.php @@ -23,6 +23,13 @@ protected function setUp(): void $this->configurationRuleFilter = $this->make(ConfigurationRuleFilter::class); } + protected function tearDown(): void + { + // the filter is a shared container service; reset it so a configured onlyRules/isPhpOnly + // does not leak into other tests in the same process + $this->configurationRuleFilter->setConfiguration(new Configuration()); + } + public function testPhpOnlyKeepsMinPhpVersionRules(): void { $stringableForToStringRector = $this->make(StringableForToStringRector::class);