From 0592a5f5c200d46e99afbcdb3b6054884f1cab7d Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 21 Jun 2026 17:53:23 +0200 Subject: [PATCH] Cache --only / --only-suffix runs under a rule-scoped key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8029 stopped caching selective runs to fix full-run poisoning (a file clean under one rule was cached as clean for all). That also removed the cache on repeated --only / --only-suffix runs — a drawback raised in that thread, and what #7641 set out to solve. Scope the per-file cache key by the active rule selection instead. Selective and full runs then use distinct, coexisting cache entries: a repeated --only run is served from cache, a full run is never poisoned by it, and nothing is cleared, so there is no back-and-forth thrash (the behaviour that closed #7641). The scope is set in both run() and processFiles() because parallel workers invoke processFiles() directly via WorkerCommand, bypassing run(), and must write entries under the same scope the main process reads. --- src/Application/ApplicationFileProcessor.php | 13 ++++++++----- src/Caching/Detector/ChangedFilesDetector.php | 13 ++++++++++++- .../ApplicationFileProcessorTest.php | 15 ++++++++++++--- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/Application/ApplicationFileProcessor.php b/src/Application/ApplicationFileProcessor.php index 6cfd9b99bb6..09a9b1e7b73 100644 --- a/src/Application/ApplicationFileProcessor.php +++ b/src/Application/ApplicationFileProcessor.php @@ -56,6 +56,9 @@ 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()); + $filePaths = $this->filesFinder->findFilesInPaths($configuration->getPaths(), $configuration); // no files found @@ -121,6 +124,9 @@ public function processFiles( ?callable $preFileCallback = null, ?callable $postFileCallback = null ): ProcessResult { + // also set here: parallel workers reach processFiles() via WorkerCommand, bypassing run() + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix()); + /** @var SystemError[] $systemErrors */ $systemErrors = []; @@ -179,11 +185,8 @@ private function processFile(File $file, Configuration $configuration): FileProc if ($fileProcessResult->getSystemErrors() !== []) { $this->changedFilesDetector->invalidateFile($file->getFilePath()); } elseif (! $configuration->isDryRun() || ! $fileProcessResult->getFileDiff() instanceof FileDiff) { - // 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()); - } + // selective runs are safe to cache now — the key is scoped to the rule selection + $this->changedFilesDetector->cacheFile($file->getFilePath()); } return $fileProcessResult; diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index 46718bab661..93bc528806f 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -21,6 +21,9 @@ final class ChangedFilesDetector */ private array $cacheableFiles = []; + // scopes the per-file cache key to the active --only / --only-suffix selection (empty = full run) + private string $scopeSuffix = ''; + public function __construct( private readonly FileHashComputer $fileHashComputer, private readonly Cache $cache, @@ -28,6 +31,14 @@ public function __construct( ) { } + public function setActiveScope(?string $onlyRule, ?string $onlySuffix): void + { + // each selection gets its own cache key, so --only and full runs coexist without clearing or poisoning + $this->scopeSuffix = ($onlyRule === null && $onlySuffix === null) + ? '' + : '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? ''); + } + public function cacheFile(string $filePath): void { $filePathCacheKey = $this->getFilePathCacheKey($filePath); @@ -95,7 +106,7 @@ private function resolvePath(string $filePath): string private function getFilePathCacheKey(string $filePath): string { - return $this->fileHasher->hash($this->resolvePath($filePath)); + return $this->fileHasher->hash($this->resolvePath($filePath) . $this->scopeSuffix); } private function hashFile(string $filePath): string diff --git a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php index 09c75c0c246..286a9b64ef9 100644 --- a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php +++ b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php @@ -38,7 +38,7 @@ public function testCleanFileIsCachedAsUnchanged(): void $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); } - public function testOnlyRuleRunDoesNotCacheFileAsUnchanged(): void + public function testOnlyRuleRunCachesUnderOwnScopeWithoutPoisoningFullRun(): void { $filePath = __DIR__ . '/Source/CleanFile.php'; @@ -47,11 +47,16 @@ public function testOnlyRuleRunDoesNotCacheFileAsUnchanged(): void onlyRule: RemoveEmptyClassMethodRector::class )); - // a file clean under one rule is not necessarily clean under all rules + // a repeated --only run hits its own scoped cache entry + $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->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } - public function testOnlySuffixRunDoesNotCacheFileAsUnchanged(): void + public function testOnlySuffixRunCachesUnderOwnScopeWithoutPoisoningFullRun(): void { $filePath = __DIR__ . '/Source/CleanFile.php'; @@ -60,6 +65,10 @@ public function testOnlySuffixRunDoesNotCacheFileAsUnchanged(): void onlySuffix: 'Controller.php' )); + $this->changedFilesDetector->setActiveScope(null, 'Controller.php'); + $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); + + $this->changedFilesDetector->setActiveScope(null, null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } }