From d7399bad6cbba07a39f680d00a943c5dc1d32d13 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 10 Jun 2026 21:22:27 +0200 Subject: [PATCH 1/3] fix: do not cache files as unchanged on --only and --only-suffix runs A file clean under one rule is not necessarily clean under all rules. An --only run cached such files as unchanged, so the next full run silently skipped their pending changes until --clear-cache. Covered by a new e2e scenario that fails on main: an --only run followed by a full run must still report the change pending under the other rule. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e_with_cache.yaml | 22 ++++++++ e2e/only-rule-keeps-cache/composer.json | 7 +++ .../e2eTestRunnerOnlyRule.php | 56 +++++++++++++++++++ .../expected-output.diff | 23 ++++++++ e2e/only-rule-keeps-cache/rector.php | 17 ++++++ e2e/only-rule-keeps-cache/src/AlwaysTrue.php | 12 ++++ src/Application/ApplicationFileProcessor.php | 6 +- 7 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 e2e/only-rule-keeps-cache/composer.json create mode 100644 e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php create mode 100644 e2e/only-rule-keeps-cache/expected-output.diff create mode 100644 e2e/only-rule-keeps-cache/rector.php create mode 100644 e2e/only-rule-keeps-cache/src/AlwaysTrue.php diff --git a/.github/workflows/e2e_with_cache.yaml b/.github/workflows/e2e_with_cache.yaml index 97b6062b6a4..93f24050179 100644 --- a/.github/workflows/e2e_with_cache.yaml +++ b/.github/workflows/e2e_with_cache.yaml @@ -74,3 +74,25 @@ jobs: - run: php e2eTestRunnerCacheInvalidation.php working-directory: e2e/cache-meta-extension + + only_rule_keeps_cache: + runs-on: ubuntu-latest + timeout-minutes: 3 + + name: End to end test - e2e/only-rule-keeps-cache + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - run: composer install --ansi + + - run: composer install --ansi + working-directory: e2e/only-rule-keeps-cache + + - run: php e2eTestRunnerOnlyRule.php + working-directory: e2e/only-rule-keeps-cache diff --git a/e2e/only-rule-keeps-cache/composer.json b/e2e/only-rule-keeps-cache/composer.json new file mode 100644 index 00000000000..5468cd74606 --- /dev/null +++ b/e2e/only-rule-keeps-cache/composer.json @@ -0,0 +1,7 @@ +{ + "require": { + "php": "^8.1" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php b/e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php new file mode 100644 index 00000000000..5beb7f684e2 --- /dev/null +++ b/e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php @@ -0,0 +1,56 @@ +#!/usr/bin/env php +create(); + +$e2eCommand = 'php ' . $rectorBin . ' process --dry-run --no-ansi -a ' . $autoloadFile; + +// Step 1: --only run, file is clean under this one rule +$onlyRule = escapeshellarg('Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector'); + +$output = []; +exec($e2eCommand . ' --clear-cache --only ' . $onlyRule, $output, $exitCode); +$outputString = trim(implode("\n", $output)); + +if (! str_contains($outputString, '[OK] Rector is done!')) { + $symfonyStyle->error('Step 1 failed: Expected no changes under --only RemoveEmptyClassMethodRector'); + $symfonyStyle->writeln($outputString); + exit(Command::FAILURE); +} + +$symfonyStyle->success('Step 1 passed: No changes under --only RemoveEmptyClassMethodRector'); + +// Step 2: full run without --clear-cache must still report the pending change +$output = []; +exec($e2eCommand, $output, $exitCode); +$outputString = trim(implode("\n", $output)); +$outputString = str_replace(__DIR__, '.', $outputString); + +$expectedOutput = trim((string) file_get_contents(__DIR__ . '/expected-output.diff')); + +if ($outputString === $expectedOutput) { + $symfonyStyle->success('Step 2 passed: Full run still reports the pending change'); + exit(Command::SUCCESS); +} + +$symfonyStyle->error('Step 2 failed: The --only run cached the file as clean, hiding its pending change'); +$symfonyStyle->writeln($outputString); +exit(Command::FAILURE); diff --git a/e2e/only-rule-keeps-cache/expected-output.diff b/e2e/only-rule-keeps-cache/expected-output.diff new file mode 100644 index 00000000000..23987e01440 --- /dev/null +++ b/e2e/only-rule-keeps-cache/expected-output.diff @@ -0,0 +1,23 @@ +1 file with changes +=================== + +1) src/AlwaysTrue.php:4 + + ---------- begin diff ---------- +@@ Line 4 @@ + { + public function run() + { +- if (1 === 1) { +- } +- + return 'no'; + } + } + ----------- end diff ----------- + +Applied rules: + * RemoveAlwaysTrueIfConditionRector + + + [OK] 1 file would have been changed (dry-run) by Rector diff --git a/e2e/only-rule-keeps-cache/rector.php b/e2e/only-rule-keeps-cache/rector.php new file mode 100644 index 00000000000..e1a67839aab --- /dev/null +++ b/e2e/only-rule-keeps-cache/rector.php @@ -0,0 +1,17 @@ +cacheClass(FileCacheStorage::class); + + $rectorConfig->paths([__DIR__ . '/src']); + + $rectorConfig->rule(RemoveEmptyClassMethodRector::class); + $rectorConfig->rule(RemoveAlwaysTrueIfConditionRector::class); +}; diff --git a/e2e/only-rule-keeps-cache/src/AlwaysTrue.php b/e2e/only-rule-keeps-cache/src/AlwaysTrue.php new file mode 100644 index 00000000000..7b70b9e9ccd --- /dev/null +++ b/e2e/only-rule-keeps-cache/src/AlwaysTrue.php @@ -0,0 +1,12 @@ +getSystemErrors() !== []) { $this->changedFilesDetector->invalidateFile($file->getFilePath()); } elseif (! $configuration->isDryRun() || ! $fileProcessResult->getFileDiff() instanceof FileDiff) { - $this->changedFilesDetector->cacheFile($file->getFilePath()); + // a file clean under a subset of rules is not necessarily clean under all rules, + // caching it would hide its pending changes from the next full run + if ($configuration->getOnlyRule() === null && $configuration->getOnlySuffix() === null) { + $this->changedFilesDetector->cacheFile($file->getFilePath()); + } } return $fileProcessResult; From bba85deb3fa7b8c91025528c12042d949bccaa1a Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 10 Jun 2026 21:44:26 +0200 Subject: [PATCH 2/3] test: replace e2e scenario with a unit test ApplicationFileProcessor::processFiles() with no rules registered reaches the cache-write branch directly, so the guard is testable without an e2e project: an --only / --only-suffix run must leave the file uncached, a plain dry-run caches it. Fails on main, passes with the fix. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e_with_cache.yaml | 22 ------- e2e/only-rule-keeps-cache/composer.json | 7 -- .../e2eTestRunnerOnlyRule.php | 56 ---------------- .../expected-output.diff | 23 ------- e2e/only-rule-keeps-cache/rector.php | 17 ----- e2e/only-rule-keeps-cache/src/AlwaysTrue.php | 12 ---- .../ApplicationFileProcessorTest.php | 65 +++++++++++++++++++ .../Source/clean_file.php | 11 ++++ 8 files changed, 76 insertions(+), 137 deletions(-) delete mode 100644 e2e/only-rule-keeps-cache/composer.json delete mode 100644 e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php delete mode 100644 e2e/only-rule-keeps-cache/expected-output.diff delete mode 100644 e2e/only-rule-keeps-cache/rector.php delete mode 100644 e2e/only-rule-keeps-cache/src/AlwaysTrue.php create mode 100644 tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php create mode 100644 tests/Application/ApplicationFileProcessor/Source/clean_file.php diff --git a/.github/workflows/e2e_with_cache.yaml b/.github/workflows/e2e_with_cache.yaml index 93f24050179..97b6062b6a4 100644 --- a/.github/workflows/e2e_with_cache.yaml +++ b/.github/workflows/e2e_with_cache.yaml @@ -74,25 +74,3 @@ jobs: - run: php e2eTestRunnerCacheInvalidation.php working-directory: e2e/cache-meta-extension - - only_rule_keeps_cache: - runs-on: ubuntu-latest - timeout-minutes: 3 - - name: End to end test - e2e/only-rule-keeps-cache - - steps: - - uses: actions/checkout@v4 - - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - coverage: none - - - run: composer install --ansi - - - run: composer install --ansi - working-directory: e2e/only-rule-keeps-cache - - - run: php e2eTestRunnerOnlyRule.php - working-directory: e2e/only-rule-keeps-cache diff --git a/e2e/only-rule-keeps-cache/composer.json b/e2e/only-rule-keeps-cache/composer.json deleted file mode 100644 index 5468cd74606..00000000000 --- a/e2e/only-rule-keeps-cache/composer.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "require": { - "php": "^8.1" - }, - "minimum-stability": "dev", - "prefer-stable": true -} diff --git a/e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php b/e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php deleted file mode 100644 index 5beb7f684e2..00000000000 --- a/e2e/only-rule-keeps-cache/e2eTestRunnerOnlyRule.php +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env php -create(); - -$e2eCommand = 'php ' . $rectorBin . ' process --dry-run --no-ansi -a ' . $autoloadFile; - -// Step 1: --only run, file is clean under this one rule -$onlyRule = escapeshellarg('Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector'); - -$output = []; -exec($e2eCommand . ' --clear-cache --only ' . $onlyRule, $output, $exitCode); -$outputString = trim(implode("\n", $output)); - -if (! str_contains($outputString, '[OK] Rector is done!')) { - $symfonyStyle->error('Step 1 failed: Expected no changes under --only RemoveEmptyClassMethodRector'); - $symfonyStyle->writeln($outputString); - exit(Command::FAILURE); -} - -$symfonyStyle->success('Step 1 passed: No changes under --only RemoveEmptyClassMethodRector'); - -// Step 2: full run without --clear-cache must still report the pending change -$output = []; -exec($e2eCommand, $output, $exitCode); -$outputString = trim(implode("\n", $output)); -$outputString = str_replace(__DIR__, '.', $outputString); - -$expectedOutput = trim((string) file_get_contents(__DIR__ . '/expected-output.diff')); - -if ($outputString === $expectedOutput) { - $symfonyStyle->success('Step 2 passed: Full run still reports the pending change'); - exit(Command::SUCCESS); -} - -$symfonyStyle->error('Step 2 failed: The --only run cached the file as clean, hiding its pending change'); -$symfonyStyle->writeln($outputString); -exit(Command::FAILURE); diff --git a/e2e/only-rule-keeps-cache/expected-output.diff b/e2e/only-rule-keeps-cache/expected-output.diff deleted file mode 100644 index 23987e01440..00000000000 --- a/e2e/only-rule-keeps-cache/expected-output.diff +++ /dev/null @@ -1,23 +0,0 @@ -1 file with changes -=================== - -1) src/AlwaysTrue.php:4 - - ---------- begin diff ---------- -@@ Line 4 @@ - { - public function run() - { -- if (1 === 1) { -- } -- - return 'no'; - } - } - ----------- end diff ----------- - -Applied rules: - * RemoveAlwaysTrueIfConditionRector - - - [OK] 1 file would have been changed (dry-run) by Rector diff --git a/e2e/only-rule-keeps-cache/rector.php b/e2e/only-rule-keeps-cache/rector.php deleted file mode 100644 index e1a67839aab..00000000000 --- a/e2e/only-rule-keeps-cache/rector.php +++ /dev/null @@ -1,17 +0,0 @@ -cacheClass(FileCacheStorage::class); - - $rectorConfig->paths([__DIR__ . '/src']); - - $rectorConfig->rule(RemoveEmptyClassMethodRector::class); - $rectorConfig->rule(RemoveAlwaysTrueIfConditionRector::class); -}; diff --git a/e2e/only-rule-keeps-cache/src/AlwaysTrue.php b/e2e/only-rule-keeps-cache/src/AlwaysTrue.php deleted file mode 100644 index 7b70b9e9ccd..00000000000 --- a/e2e/only-rule-keeps-cache/src/AlwaysTrue.php +++ /dev/null @@ -1,12 +0,0 @@ -applicationFileProcessor = $this->make(ApplicationFileProcessor::class); + $this->changedFilesDetector = $this->make(ChangedFilesDetector::class); + } + + protected function tearDown(): void + { + $this->changedFilesDetector->clear(); + } + + public function testCleanFileIsCachedAsUnchanged(): void + { + $filePath = __DIR__ . '/Source/clean_file.php'; + + $this->applicationFileProcessor->processFiles([$filePath], new Configuration(isDryRun: true)); + + $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); + } + + public function testOnlyRuleRunDoesNotCacheFileAsUnchanged(): void + { + $filePath = __DIR__ . '/Source/clean_file.php'; + + $this->applicationFileProcessor->processFiles([$filePath], new Configuration( + isDryRun: true, + onlyRule: RemoveEmptyClassMethodRector::class + )); + + // a file clean under one rule is not necessarily clean under all rules + $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); + } + + public function testOnlySuffixRunDoesNotCacheFileAsUnchanged(): void + { + $filePath = __DIR__ . '/Source/clean_file.php'; + + $this->applicationFileProcessor->processFiles([$filePath], new Configuration( + isDryRun: true, + onlySuffix: 'Controller.php' + )); + + $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); + } +} diff --git a/tests/Application/ApplicationFileProcessor/Source/clean_file.php b/tests/Application/ApplicationFileProcessor/Source/clean_file.php new file mode 100644 index 00000000000..d8f002fcff5 --- /dev/null +++ b/tests/Application/ApplicationFileProcessor/Source/clean_file.php @@ -0,0 +1,11 @@ + Date: Wed, 10 Jun 2026 21:54:17 +0200 Subject: [PATCH 3/3] fix: PSR-4 compliant test fixture name and namespace Co-Authored-By: Claude Opus 4.8 --- .../ApplicationFileProcessorTest.php | 6 +++--- .../Source/{clean_file.php => CleanFile.php} | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) rename tests/Application/ApplicationFileProcessor/Source/{clean_file.php => CleanFile.php} (67%) diff --git a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php index 51a0a15bc31..09c75c0c246 100644 --- a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php +++ b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php @@ -31,7 +31,7 @@ protected function tearDown(): void public function testCleanFileIsCachedAsUnchanged(): void { - $filePath = __DIR__ . '/Source/clean_file.php'; + $filePath = __DIR__ . '/Source/CleanFile.php'; $this->applicationFileProcessor->processFiles([$filePath], new Configuration(isDryRun: true)); @@ -40,7 +40,7 @@ public function testCleanFileIsCachedAsUnchanged(): void public function testOnlyRuleRunDoesNotCacheFileAsUnchanged(): void { - $filePath = __DIR__ . '/Source/clean_file.php'; + $filePath = __DIR__ . '/Source/CleanFile.php'; $this->applicationFileProcessor->processFiles([$filePath], new Configuration( isDryRun: true, @@ -53,7 +53,7 @@ public function testOnlyRuleRunDoesNotCacheFileAsUnchanged(): void public function testOnlySuffixRunDoesNotCacheFileAsUnchanged(): void { - $filePath = __DIR__ . '/Source/clean_file.php'; + $filePath = __DIR__ . '/Source/CleanFile.php'; $this->applicationFileProcessor->processFiles([$filePath], new Configuration( isDryRun: true, diff --git a/tests/Application/ApplicationFileProcessor/Source/clean_file.php b/tests/Application/ApplicationFileProcessor/Source/CleanFile.php similarity index 67% rename from tests/Application/ApplicationFileProcessor/Source/clean_file.php rename to tests/Application/ApplicationFileProcessor/Source/CleanFile.php index d8f002fcff5..4dce24c580d 100644 --- a/tests/Application/ApplicationFileProcessor/Source/clean_file.php +++ b/tests/Application/ApplicationFileProcessor/Source/CleanFile.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Rector\Tests\Application\ApplicationFileProcessor\Source; + final class CleanFile { public function run(): string