From 8040f3de6c6d2838550970d1804d2721fb9f1710 Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Sun, 22 Mar 2026 09:52:41 +0100 Subject: [PATCH 1/2] [Caching] Add CacheMetaExtensionInterface for custom cache invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same mechanism as PHPStan's ResultCacheMetaExtension — extensions implement getKey() and getHash() to provide additional metadata that is folded into the file cache key. When any extension's hash changes, all cached files are reprocessed. --- src/Caching/Config/FileHashComputer.php | 22 +++++++++++++++- .../Contract/CacheMetaExtensionInterface.php | 26 +++++++++++++++++++ src/Config/RectorConfig.php | 12 +++++++++ src/Configuration/RectorConfigBuilder.php | 20 ++++++++++++++ .../LazyContainerFactory.php | 6 +++++ 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/Caching/Contract/CacheMetaExtensionInterface.php diff --git a/src/Caching/Config/FileHashComputer.php b/src/Caching/Config/FileHashComputer.php index 92b46073c42..69905400560 100644 --- a/src/Caching/Config/FileHashComputer.php +++ b/src/Caching/Config/FileHashComputer.php @@ -5,6 +5,7 @@ namespace Rector\Caching\Config; use Rector\Application\VersionResolver; +use Rector\Caching\Contract\CacheMetaExtensionInterface; use Rector\Configuration\Parameter\SimpleParameterProvider; use Rector\Exception\ShouldNotHappenException; @@ -13,12 +14,31 @@ */ final class FileHashComputer { + /** + * @param CacheMetaExtensionInterface[] $cacheMetaExtensions + */ + public function __construct( + private readonly array $cacheMetaExtensions = [] + ) { + } + public function compute(string $filePath): string { $this->ensureIsPhp($filePath); $parametersHash = SimpleParameterProvider::hash(); - return sha1($filePath . $parametersHash . VersionResolver::PACKAGE_VERSION); + $extensionHash = $this->computeExtensionHash(); + return sha1($filePath . $parametersHash . $extensionHash . VersionResolver::PACKAGE_VERSION); + } + + private function computeExtensionHash(): string + { + $extensionHash = ''; + foreach ($this->cacheMetaExtensions as $cacheMetaExtension) { + $extensionHash .= $cacheMetaExtension->getKey() . ':' . $cacheMetaExtension->getHash(); + } + + return $extensionHash; } private function ensureIsPhp(string $filePath): void diff --git a/src/Caching/Contract/CacheMetaExtensionInterface.php b/src/Caching/Contract/CacheMetaExtensionInterface.php new file mode 100644 index 00000000000..dd0e05657c7 --- /dev/null +++ b/src/Caching/Contract/CacheMetaExtensionInterface.php @@ -0,0 +1,26 @@ + $cacheMetaExtensionClass + */ + public function cacheMetaExtension(string $cacheMetaExtensionClass): void + { + Assert::isAOf($cacheMetaExtensionClass, CacheMetaExtensionInterface::class); + + $this->singleton($cacheMetaExtensionClass); + $this->tag($cacheMetaExtensionClass, CacheMetaExtensionInterface::class); + } + /** * @see https://github.com/nikic/PHP-Parser/issues/723#issuecomment-712401963 */ diff --git a/src/Configuration/RectorConfigBuilder.php b/src/Configuration/RectorConfigBuilder.php index 6a5474b1f9b..bb7c3c8b676 100644 --- a/src/Configuration/RectorConfigBuilder.php +++ b/src/Configuration/RectorConfigBuilder.php @@ -7,6 +7,7 @@ use PhpParser\NodeVisitor; use Rector\Bridge\SetProviderCollector; use Rector\Bridge\SetRectorsResolver; +use Rector\Caching\Contract\CacheMetaExtensionInterface; use Rector\Caching\Contract\ValueObject\Storage\CacheStorageInterface; use Rector\Composer\InstalledPackageResolver; use Rector\Config\Level\CodeQualityLevel; @@ -91,6 +92,11 @@ final class RectorConfigBuilder private ?string $containerCacheDirectory = null; + /** + * @var array> + */ + private array $cacheMetaExtensions = []; + private ?bool $parallel = null; private int $parallelTimeoutSeconds = 120; @@ -316,6 +322,10 @@ public function __invoke(RectorConfig $rectorConfig): void $rectorConfig->containerCacheDirectory($this->containerCacheDirectory); } + foreach ($this->cacheMetaExtensions as $cacheMetaExtensionClass) { + $rectorConfig->cacheMetaExtension($cacheMetaExtensionClass); + } + if ($this->importNames || $this->importDocBlockNames) { $rectorConfig->importNames($this->importNames, $this->importDocBlockNames); $rectorConfig->importShortClasses($this->importShortClasses); @@ -880,6 +890,16 @@ public function withCache( return $this; } + /** + * @param class-string $cacheMetaExtensionClass + */ + public function withCacheMetaExtension(string $cacheMetaExtensionClass): self + { + $this->cacheMetaExtensions[] = $cacheMetaExtensionClass; + + return $this; + } + /** * @param class-string $rectorClass * @param mixed[] $configuration diff --git a/src/DependencyInjection/LazyContainerFactory.php b/src/DependencyInjection/LazyContainerFactory.php index 0b771550e68..04d7da27c22 100644 --- a/src/DependencyInjection/LazyContainerFactory.php +++ b/src/DependencyInjection/LazyContainerFactory.php @@ -36,6 +36,8 @@ use Rector\BetterPhpDocParser\PhpDocParser\StaticDoctrineAnnotationParser\PlainValueParser; use Rector\Caching\Cache; use Rector\Caching\CacheFactory; +use Rector\Caching\Config\FileHashComputer; +use Rector\Caching\Contract\CacheMetaExtensionInterface; use Rector\ChangesReporting\Contract\Output\OutputFormatterInterface; use Rector\ChangesReporting\Output\ConsoleOutputFormatter; use Rector\ChangesReporting\Output\GitHubOutputFormatter; @@ -482,6 +484,10 @@ static function (Container $container): DynamicSourceLocatorProvider { return $cacheFactory->create(); }); + $rectorConfig->when(FileHashComputer::class) + ->needs('$cacheMetaExtensions') + ->giveTagged(CacheMetaExtensionInterface::class); + // tagged services $rectorConfig->when(BetterPhpDocParser::class) ->needs('$phpDocNodeDecorators') From 05ce9fb5177e4103f8949789f1f0def48609e21e Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Tue, 24 Mar 2026 15:27:42 +0100 Subject: [PATCH 2/2] Add e2e test for CacheMetaExtensionInterface cache invalidation Proves the extension actually invalidates cache by using a conditional rule that only triggers when an external file changes value. --- .github/workflows/e2e_with_cache.yaml | 22 +++++ e2e/cache-meta-extension/.gitignore | 1 + e2e/cache-meta-extension/composer.json | 12 +++ .../e2eTestRunnerCacheInvalidation.php | 66 ++++++++++++++ e2e/cache-meta-extension/enabled.txt | 1 + e2e/cache-meta-extension/expected-output.diff | 21 +++++ e2e/cache-meta-extension/rector.php | 21 +++++ .../src/ConditionalEmptyConstructorRector.php | 86 +++++++++++++++++++ .../src/DeadConstructor.php | 8 ++ .../src/EnabledFlagCacheMetaExtension.php | 20 +++++ 10 files changed, 258 insertions(+) create mode 100644 e2e/cache-meta-extension/.gitignore create mode 100644 e2e/cache-meta-extension/composer.json create mode 100644 e2e/cache-meta-extension/e2eTestRunnerCacheInvalidation.php create mode 100644 e2e/cache-meta-extension/enabled.txt create mode 100644 e2e/cache-meta-extension/expected-output.diff create mode 100644 e2e/cache-meta-extension/rector.php create mode 100644 e2e/cache-meta-extension/src/ConditionalEmptyConstructorRector.php create mode 100644 e2e/cache-meta-extension/src/DeadConstructor.php create mode 100644 e2e/cache-meta-extension/src/EnabledFlagCacheMetaExtension.php diff --git a/.github/workflows/e2e_with_cache.yaml b/.github/workflows/e2e_with_cache.yaml index 19a045fb8a2..97b6062b6a4 100644 --- a/.github/workflows/e2e_with_cache.yaml +++ b/.github/workflows/e2e_with_cache.yaml @@ -52,3 +52,25 @@ jobs: # this tests that a 2nd run with cache and "--dry-run" gives same results, see https://github.com/rectorphp/rector-src/pull/3614#issuecomment-1507742338 - run: php ../e2eTestRunnerWithCache.php working-directory: ${{ matrix.directory }} + + cache_meta_extension: + runs-on: ubuntu-latest + timeout-minutes: 3 + + name: End to end test - e2e/cache-meta-extension + + 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/cache-meta-extension + + - run: php e2eTestRunnerCacheInvalidation.php + working-directory: e2e/cache-meta-extension diff --git a/e2e/cache-meta-extension/.gitignore b/e2e/cache-meta-extension/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/cache-meta-extension/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/cache-meta-extension/composer.json b/e2e/cache-meta-extension/composer.json new file mode 100644 index 00000000000..513fa7d901b --- /dev/null +++ b/e2e/cache-meta-extension/composer.json @@ -0,0 +1,12 @@ +{ + "require": { + "php": "^8.1" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/e2e/cache-meta-extension/e2eTestRunnerCacheInvalidation.php b/e2e/cache-meta-extension/e2eTestRunnerCacheInvalidation.php new file mode 100644 index 00000000000..01c2f549756 --- /dev/null +++ b/e2e/cache-meta-extension/e2eTestRunnerCacheInvalidation.php @@ -0,0 +1,66 @@ +#!/usr/bin/env php +create(); + +$e2eCommand = 'php '. $rectorBin .' process --dry-run --no-ansi -a '. $autoloadFile; + +// Step 1: enabled=false, clear cache → no changes +file_put_contents(__DIR__ . '/enabled.txt', "false\n"); + +$output = []; +exec($e2eCommand . ' --clear-cache', $output, $exitCode); +$outputString = trim(implode("\n", $output)); + +if (! str_contains($outputString, '[OK] Rector is done!')) { + $symfonyStyle->error('Step 1 failed: Expected no changes with enabled=false'); + $symfonyStyle->writeln($outputString); + exit(Command::FAILURE); +} + +$symfonyStyle->success('Step 1 passed: No changes with enabled=false'); + +// Step 2: enabled=true, no --clear-cache → cache meta invalidated → rule triggers +file_put_contents(__DIR__ . '/enabled.txt', "true\n"); + +$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')); + +// Restore enabled.txt +file_put_contents(__DIR__ . '/enabled.txt', "false\n"); + +if ($outputString === $expectedOutput) { + $symfonyStyle->success('Step 2 passed: Cache invalidated, rule triggered'); + exit(Command::SUCCESS); +} + +$symfonyStyle->error('Step 2 failed: Expected cache invalidation to trigger the rule'); + +$defaultDiffer = new DefaultDiffer(); +$colorConsoleDiffFormatter = new ColorConsoleDiffFormatter(); +$diff = $colorConsoleDiffFormatter->format($defaultDiffer->diff($outputString, $expectedOutput)); +$symfonyStyle->writeln($diff); + +exit(Command::FAILURE); diff --git a/e2e/cache-meta-extension/enabled.txt b/e2e/cache-meta-extension/enabled.txt new file mode 100644 index 00000000000..c508d5366f7 --- /dev/null +++ b/e2e/cache-meta-extension/enabled.txt @@ -0,0 +1 @@ +false diff --git a/e2e/cache-meta-extension/expected-output.diff b/e2e/cache-meta-extension/expected-output.diff new file mode 100644 index 00000000000..1c008369158 --- /dev/null +++ b/e2e/cache-meta-extension/expected-output.diff @@ -0,0 +1,21 @@ +1 file with changes +=================== + +1) src/DeadConstructor.php:2 + + ---------- begin diff ---------- +@@ @@ + + final class DeadConstructor + { +- public function __construct() +- { +- } + } + ----------- end diff ----------- + +Applied rules: + * ConditionalEmptyConstructorRector + + + [OK] 1 file would have been changed (dry-run) by Rector \ No newline at end of file diff --git a/e2e/cache-meta-extension/rector.php b/e2e/cache-meta-extension/rector.php new file mode 100644 index 00000000000..cc8df07efcf --- /dev/null +++ b/e2e/cache-meta-extension/rector.php @@ -0,0 +1,21 @@ +cacheClass(FileCacheStorage::class); + + $rectorConfig->paths([ + __DIR__ . '/src/DeadConstructor.php', + ]); + + $rectorConfig->rule(ConditionalEmptyConstructorRector::class); + $rectorConfig->cacheMetaExtension(EnabledFlagCacheMetaExtension::class); +}; diff --git a/e2e/cache-meta-extension/src/ConditionalEmptyConstructorRector.php b/e2e/cache-meta-extension/src/ConditionalEmptyConstructorRector.php new file mode 100644 index 00000000000..c34195954bc --- /dev/null +++ b/e2e/cache-meta-extension/src/ConditionalEmptyConstructorRector.php @@ -0,0 +1,86 @@ +> + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Class_ + { + $enabled = trim((string) file_get_contents(__DIR__ . '/../enabled.txt')); + + if ($enabled !== 'true') { + return null; + } + + $hasChanged = false; + + foreach ($node->stmts as $key => $stmt) { + if (! $stmt instanceof ClassMethod) { + continue; + } + + if (! $this->isName($stmt, '__construct')) { + continue; + } + + if ($stmt->stmts !== null && $stmt->stmts !== []) { + continue; + } + + unset($node->stmts[$key]); + $hasChanged = true; + } + + if ($hasChanged) { + return $node; + } + + return null; + } +} diff --git a/e2e/cache-meta-extension/src/DeadConstructor.php b/e2e/cache-meta-extension/src/DeadConstructor.php new file mode 100644 index 00000000000..03d800c4879 --- /dev/null +++ b/e2e/cache-meta-extension/src/DeadConstructor.php @@ -0,0 +1,8 @@ +