From ddc24ce2202fffcdd5fb4acddc8f1fda8d75ac01 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sat, 29 Aug 2026 10:27:04 +0200 Subject: [PATCH] [Caching] Invalidate on configured rule value change, reuse full-run cache for --only --- src/Caching/Detector/ChangedFilesDetector.php | 10 +++++-- src/Config/RectorConfig.php | 3 ++ src/Configuration/Option.php | 5 ++++ .../FileHashComputer/FileHashComputerTest.php | 25 ++++++++++++++++ .../Detector/ChangedFilesDetectorTest.php | 30 +++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index 525b59d2f81..cf9afb54807 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -61,8 +61,14 @@ public function addCacheableFile(string $filePath): void public function hasFileChanged(string $filePath): bool { - $fileInfoCacheKey = $this->getFilePathCacheKey($filePath); - $cachedValue = $this->cache->load($fileInfoCacheKey, CacheKey::FILE_HASH_KEY); + $cachedValue = $this->cache->load($this->getFilePathCacheKey($filePath), CacheKey::FILE_HASH_KEY); + + // a scoped (--only) run reuses the full-run cache: a file left clean by all rules stays + // clean under a single rule too, and the content is still compared below + if ($cachedValue === null && $this->scopeSuffix !== '') { + $unscopedCacheKey = $this->fileHasher->hash($this->resolvePath($filePath)); + $cachedValue = $this->cache->load($unscopedCacheKey, CacheKey::FILE_HASH_KEY); + } if ($cachedValue !== null) { $currentFileHash = $this->hashFile($filePath); diff --git a/src/Config/RectorConfig.php b/src/Config/RectorConfig.php index ac3ed8bcd80..b57fb7e4129 100644 --- a/src/Config/RectorConfig.php +++ b/src/Config/RectorConfig.php @@ -215,6 +215,9 @@ public function ruleWithConfiguration(string $rectorClass, array $configuration) $configuration ); + // feed values into the cache hash, so a changed configuration invalidates the cache + SimpleParameterProvider::setParameter(Option::RULE_CONFIGURATIONS, $this->ruleConfigurations); + $this->rule($rectorClass); $this->afterResolving($rectorClass, function (ConfigurableRectorInterface $configurableRector) use ( diff --git a/src/Configuration/Option.php b/src/Configuration/Option.php index fd5937e847b..7f3db083793 100644 --- a/src/Configuration/Option.php +++ b/src/Configuration/Option.php @@ -218,6 +218,11 @@ final class Option */ public const string REGISTERED_RECTOR_SETS = 'registered_rector_sets'; + /** + * @internal For cache invalidation when a configurable rule value changes + */ + public const string RULE_CONFIGURATIONS = 'rule_configurations'; + /** * @internal For verify RectorConfigBuilder instance recreated */ diff --git a/tests/Caching/Config/FileHashComputer/FileHashComputerTest.php b/tests/Caching/Config/FileHashComputer/FileHashComputerTest.php index 403aee7fd51..2d5717369e4 100644 --- a/tests/Caching/Config/FileHashComputer/FileHashComputerTest.php +++ b/tests/Caching/Config/FileHashComputer/FileHashComputerTest.php @@ -23,6 +23,11 @@ final class FileHashComputerTest extends AbstractLazyTestCase */ private array $originalFileExtensions = []; + /** + * @var mixed[] + */ + private array $originalRuleConfigurations = []; + protected function setUp(): void { parent::setUp(); @@ -32,12 +37,14 @@ protected function setUp(): void // the parameter bag is a global static shared across the whole test process, restore it after $this->originalRules = SimpleParameterProvider::provideArrayParameter(Option::REGISTERED_RECTOR_RULES); $this->originalFileExtensions = SimpleParameterProvider::provideArrayParameter(Option::FILE_EXTENSIONS); + $this->originalRuleConfigurations = SimpleParameterProvider::provideArrayParameter(Option::RULE_CONFIGURATIONS); } protected function tearDown(): void { SimpleParameterProvider::setParameter(Option::REGISTERED_RECTOR_RULES, $this->originalRules); SimpleParameterProvider::setParameter(Option::FILE_EXTENSIONS, $this->originalFileExtensions); + SimpleParameterProvider::setParameter(Option::RULE_CONFIGURATIONS, $this->originalRuleConfigurations); } public function testOutputAffectingParameterChangesHash(): void @@ -66,4 +73,22 @@ public function testRuleChangeIsExcludedFromHash(): void $this->assertSame($hashBefore, $hashAfter); } + + public function testConfiguredRuleValueChangeChangesHash(): void + { + $configFilePath = __DIR__ . '/Fixture/rector.php'; + + SimpleParameterProvider::setParameter(Option::RULE_CONFIGURATIONS, [ + 'Rector\\SomeRule' => ['old value'], + ]); + $hashBefore = $this->fileHashComputer->compute($configFilePath); + + // same rule, changed configuration value must invalidate the cache + SimpleParameterProvider::setParameter(Option::RULE_CONFIGURATIONS, [ + 'Rector\\SomeRule' => ['new value'], + ]); + $hashAfter = $this->fileHashComputer->compute($configFilePath); + + $this->assertNotSame($hashBefore, $hashAfter); + } } diff --git a/tests/Caching/Detector/ChangedFilesDetectorTest.php b/tests/Caching/Detector/ChangedFilesDetectorTest.php index 71b2a30ee3c..3fcdc0d2b40 100644 --- a/tests/Caching/Detector/ChangedFilesDetectorTest.php +++ b/tests/Caching/Detector/ChangedFilesDetectorTest.php @@ -63,6 +63,36 @@ public function testHasFileChanged(): void $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } + public function testScopedRunReusesFullRunCache(): void + { + $filePath = __DIR__ . '/Source/file.php'; + + // full run caches the file as clean + $this->changedFilesDetector->setActiveScope(null, 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->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); + } + + 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->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->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); + } + public function testCacheKeptWhenRuleRemoved(): void { $filePath = $this->cacheFileUnderRules(['Rector\\RuleA', 'Rector\\RuleB']);