From 01c933409c5a331b8518845fd3527a85696f1460 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Thu, 3 Sep 2026 23:32:33 +0200 Subject: [PATCH] [Console] Add --max-changes option to stop after N changes --- src/Application/ApplicationFileProcessor.php | 8 +++++++ src/Configuration/ConfigurationFactory.php | 22 +++++++++++++++++++ src/Configuration/Option.php | 2 ++ src/Console/ProcessConfigureDecorator.php | 7 ++++++ src/ValueObject/Configuration.php | 6 +++++ .../ApplicationFileProcessorTest.php | 21 ++++++++++++++++++ .../Source/WithTwoClosuresFirst.php | 19 ++++++++++++++++ .../Source/WithTwoClosuresSecond.php | 19 ++++++++++++++++ .../config-max-changes.php | 11 ++++++++++ 9 files changed, 115 insertions(+) create mode 100644 tests/Application/ApplicationFileProcessor/Source/WithTwoClosuresFirst.php create mode 100644 tests/Application/ApplicationFileProcessor/Source/WithTwoClosuresSecond.php create mode 100644 tests/Application/ApplicationFileProcessor/config-max-changes.php diff --git a/src/Application/ApplicationFileProcessor.php b/src/Application/ApplicationFileProcessor.php index dc3556ceb52..311b23045fa 100644 --- a/src/Application/ApplicationFileProcessor.php +++ b/src/Application/ApplicationFileProcessor.php @@ -134,6 +134,7 @@ public function processFiles( $fileDiffs = []; $totalChanged = 0; + $totalChangeCount = 0; foreach ($filePaths as $filePath) { if ($preFileCallback !== null) { $preFileCallback($filePath); @@ -152,6 +153,7 @@ public function processFiles( $currentFileDiff = $fileProcessResult->getFileDiff(); if ($currentFileDiff instanceof FileDiff) { $fileDiffs[] = $currentFileDiff; + $totalChangeCount += count($currentFileDiff->getRectorChanges()); } // progress bar on parallel handled on runParallel() @@ -162,6 +164,12 @@ public function processFiles( if ($fileProcessResult->hasChanged()) { ++$totalChanged; } + + // stop once the requested number of changes is reached, leaving the rest untouched + $maxChanges = $configuration->getMaxChanges(); + if ($maxChanges !== null && $totalChangeCount >= $maxChanges) { + break; + } } catch (Throwable $throwable) { $this->changedFilesDetector->invalidateFile($filePath); diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index 5030e638cfa..f0bf20040e0 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -10,6 +10,7 @@ use Rector\ValueObject\Configuration; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Webmozart\Assert\Assert; /** * @see \Rector\Tests\Configuration\ConfigurationFactoryTest @@ -97,6 +98,13 @@ public function createFromInput(InputInterface $input): Configuration $isParallel = false; } + $maxChanges = $this->resolveMaxChanges($input); + + // a global change counter cannot be shared across parallel workers, so enforce the limit in a single process + if ($maxChanges !== null) { + $isParallel = false; + } + $memoryLimit = $this->resolveMemoryLimit($input); $isReportingWithRealPath = SimpleParameterProvider::provideBoolParameter(Option::ABSOLUTE_FILE_PATH); @@ -140,9 +148,23 @@ public function createFromInput(InputInterface $input): Configuration $isComposerBased, $isPhpOnly, $filters, + $maxChanges, ); } + private function resolveMaxChanges(InputInterface $input): ?int + { + $maxChanges = $input->getOption(Option::MAX_CHANGES); + if ($maxChanges === null) { + return null; + } + + $maxChanges = (int) $maxChanges; + Assert::positiveInteger($maxChanges); + + return $maxChanges; + } + private function shouldShowProgressBar(InputInterface $input, string $outputFormat): bool { $noProgressBar = (bool) $input->getOption(Option::NO_PROGRESS_BAR); diff --git a/src/Configuration/Option.php b/src/Configuration/Option.php index aa10ad2c447..8abd1bef860 100644 --- a/src/Configuration/Option.php +++ b/src/Configuration/Option.php @@ -148,6 +148,8 @@ final class Option public const string RULES_SUMMARY = 'rules-summary'; + public const string MAX_CHANGES = 'max-changes'; + public const string CONFIG = 'config'; /** diff --git a/src/Console/ProcessConfigureDecorator.php b/src/Console/ProcessConfigureDecorator.php index 05b92d1b2ad..5e32a27b168 100644 --- a/src/Console/ProcessConfigureDecorator.php +++ b/src/Console/ProcessConfigureDecorator.php @@ -102,5 +102,12 @@ public static function decorate(Command $command): void InputOption::VALUE_NONE, 'Show summary of rules applied during the run.' ); + + $command->addOption( + Option::MAX_CHANGES, + null, + InputOption::VALUE_REQUIRED, + 'Stop after this many changes are made, leaving the rest untouched. Forces non-parallel run.' + ); } } diff --git a/src/ValueObject/Configuration.php b/src/ValueObject/Configuration.php index f043209ce7d..3d0f2118b4b 100644 --- a/src/ValueObject/Configuration.php +++ b/src/ValueObject/Configuration.php @@ -39,9 +39,15 @@ public function __construct( private bool $isComposerBased = false, private bool $isPhpOnly = false, private array $filters = [], + private ?int $maxChanges = null, ) { } + public function getMaxChanges(): ?int + { + return $this->maxChanges; + } + public function isComposerBased(): bool { return $this->isComposerBased; diff --git a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php index 8146993c156..c42ad956d1c 100644 --- a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php +++ b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php @@ -76,4 +76,25 @@ public function testOnlySuffixRunCachesUnderOwnScopeWithoutPoisoningFullRun(): v $this->changedFilesDetector->setActiveScope(null, null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } + + public function testMaxChangesStopsAfterLimit(): void + { + self::$rectorConfig = null; + $this->bootFromConfigFiles([__DIR__ . '/config-max-changes.php']); + $applicationFileProcessor = $this->make(ApplicationFileProcessor::class); + + $filePaths = [ + __DIR__ . '/Source/WithTwoClosuresFirst.php', + __DIR__ . '/Source/WithTwoClosuresSecond.php', + ]; + + // each file holds 2 closures = 2 changes; the first file alone reaches the limit of 2, + // so the run stops before touching the second file - proving changes are counted, not files + $processResult = $applicationFileProcessor->processFiles($filePaths, new Configuration( + isDryRun: true, + maxChanges: 2, + )); + + $this->assertCount(1, $processResult->getFileDiffs()); + } } diff --git a/tests/Application/ApplicationFileProcessor/Source/WithTwoClosuresFirst.php b/tests/Application/ApplicationFileProcessor/Source/WithTwoClosuresFirst.php new file mode 100644 index 00000000000..7c68ec70c15 --- /dev/null +++ b/tests/Application/ApplicationFileProcessor/Source/WithTwoClosuresFirst.php @@ -0,0 +1,19 @@ +cacheDirectory(sys_get_temp_dir() . '/_rector_max_changes_test'); + $rectorConfig->rule(ClosureToArrowFunctionRector::class); +};