From 8b46c13481d35a39e6c01ab13647509a15a274aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 24 Oct 2026 09:11:45 -0300 Subject: [PATCH] fix: isolate ECS caches between checkouts --- .changelog/checkout-cache-isolation.md | 7 ++++ README.md | 5 +++ src/Config/ECSConfig.php | 14 +++++++ tests/Integration/StandardsTest.php | 51 ++++++++++++++++++++++++-- 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 .changelog/checkout-cache-isolation.md diff --git a/.changelog/checkout-cache-isolation.md b/.changelog/checkout-cache-isolation.md new file mode 100644 index 0000000..4dfc797 --- /dev/null +++ b/.changelog/checkout-cache-isolation.md @@ -0,0 +1,7 @@ +--- +category: fixed +type: patch +--- + +Isolate the default ECS cache by executing checkout so another project's +configuration invalidation cannot erase its cache, preserving explicit overrides. diff --git a/README.md b/README.md index aa98c3c..cff409d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ composer test ``` `check` reports violations with a nonzero status and does not change source. +The default preset stores its ECS cache beneath the system temporary directory, +in `fast-forward-code-standards/`. +Different checkouts cannot invalidate each other's cache. Native +`withCache()` overrides remain supported and apply last; share cache directories +only when their invalidation and concurrent use are intentionally coordinated. `fix` explicitly applies ECS fixes. It does not commit, push or update dependencies. Recheck and inspect the diff afterward; failures can leave already applied fixes. An empty PHP selection is a successful no-op. Invalid options, configuration paths, diff --git a/src/Config/ECSConfig.php b/src/Config/ECSConfig.php index b4c0f80..e557406 100644 --- a/src/Config/ECSConfig.php +++ b/src/Config/ECSConfig.php @@ -12,6 +12,7 @@ use PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer; use PhpCsFixer\Fixer\Phpdoc\PhpdocNoEmptyReturnFixer; use PhpCsFixer\Fixer\Phpdoc\PhpdocToCommentFixer; +use RuntimeException; use Symplify\EasyCodingStandard\Configuration\ECSConfigBuilder; /** A deliberately non-risky PSR-12 starting point for extraction. */ @@ -22,6 +23,7 @@ final class ECSConfig * * @param callable(ECSConfigBuilder):void|null $customize Native rule imports, skips or overrides; null uses the preset unchanged. * @return ECSConfigBuilder The native builder, ready to return from an explicit ECS configuration file. + * @throws RuntimeException When the executing checkout cannot be identified for cache isolation. */ public static function configure(?callable $customize = null): ECSConfigBuilder { @@ -37,6 +39,7 @@ public static function configure(?callable $customize = null): ECSConfigBuilder ]) ->withConfiguredRule(MethodArgumentSpaceFixer::class, ['on_multiline' => 'ignore']) ->withConfiguredRule(PhpdocAlignFixer::class, ['align' => 'left']) + ->withCache(self::cacheDirectory()) ->withoutParallel(); if (null !== $customize) { $customize($config); @@ -44,4 +47,15 @@ public static function configure(?callable $customize = null): ECSConfigBuilder return $config; } + + /** Keeps ECS invalidation within the executing checkout's temporary cache; native overrides still run last. */ + private static function cacheDirectory(): string + { + $directory = getcwd(); + if (false === $directory) { + throw new RuntimeException('Cannot determine the checkout directory for the ECS cache.'); + } + + return sys_get_temp_dir() . '/fast-forward-code-standards/' . hash('sha256', $directory); + } } diff --git a/tests/Integration/StandardsTest.php b/tests/Integration/StandardsTest.php index 9d350a1..ae70f87 100644 --- a/tests/Integration/StandardsTest.php +++ b/tests/Integration/StandardsTest.php @@ -347,6 +347,46 @@ public function testPresetCanLoadThroughSymlinkWithoutAnAdjacentVendor(): void self::assertSame("directory . '/source.php')); } + /** Runs the real default preset across two checkouts without erasing the first checkout's cache. */ + public function testDefaultCachesDoNotInvalidateAnotherCheckout(): void + { + $source = "put('source.php', $source); + $this->commit(); + $this->put('.build/project-b/source.php', $source); + $second = $this->directory . '/.build/project-b'; + $temporary = $this->directory . '/.build/private-tmp'; + mkdir($temporary); + $this->git(['init', '-b', 'main', $second]); + $this->git(['-C', $second, 'add', 'source.php']); + $environment = ['TMPDIR' => $temporary]; + $cache = $temporary . '/fast-forward-code-standards/' . hash('sha256', $this->directory); + + $first = $this->cli(['check', '--all'], $environment); + self::assertSame(0, $first->getExitCode(), $first->getOutput() . $first->getErrorOutput()); + self::assertDirectoryExists($cache); + file_put_contents($cache . '/owned-marker', 'project A cache'); + + foreach (['check', 'fix', 'check', 'fix'] as $command) { + $result = $this->cli([$command, '--all', '--directory=' . $second], $environment); + + self::assertSame(0, $result->getExitCode(), $result->getOutput() . $result->getErrorOutput()); + self::assertSame('project A cache', file_get_contents($cache . '/owned-marker')); + self::assertSame($source, file_get_contents($second . '/source.php')); + self::assertSame($source, file_get_contents($this->directory . '/source.php')); + self::assertFileDoesNotExist($second . '/composer.lock'); + } + + $this->put('.build/explicit-ecs.php', 'withCache(__DIR__ . "/explicit-cache", "consumer");});'); + $override = $this->cli(['check', '--all', '--config=.build/explicit-ecs.php'], $environment); + + self::assertSame(0, $override->getExitCode(), $override->getOutput() . $override->getErrorOutput()); + self::assertDirectoryExists($this->directory . '/.build/explicit-cache'); + self::assertSame('project A cache', file_get_contents($cache . '/owned-marker')); + } + /** Writes a synthetic fixture beneath the disposable test root, creating its parents. */ private function put(string $path, string $content): void { @@ -372,10 +412,15 @@ private function commit(): string return trim($this->git(['rev-parse', 'HEAD'])); } - /** Runs the real packaged binary in the fixture with a bounded timeout and captured diagnostics. */ - private function cli(array $arguments): Process + /** + * Runs the real packaged binary in the fixture with bounded timeout and captured diagnostics. + * + * @param list $arguments Explicit CLI arguments. + * @param array $environment Invocation-local overrides, including isolated test temp directories. + */ + private function cli(array $arguments, array $environment = []): Process { - $process = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/code-standards', ...$arguments], $this->directory, timeout: 60); + $process = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/code-standards', ...$arguments], $this->directory, $environment, timeout: 60); $process->run(); return $process;