Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelog/checkout-cache-isolation.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<SHA-256 of the executing checkout path>`.
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,
Expand Down
14 changes: 14 additions & 0 deletions src/Config/ECSConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
{
Expand All @@ -37,11 +39,23 @@ 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);
}

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);
}
}
51 changes: 48 additions & 3 deletions tests/Integration/StandardsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,46 @@ public function testPresetCanLoadThroughSymlinkWithoutAnAdjacentVendor(): void
self::assertSame("<?php\n\ndeclare(strict_types=1);\n\necho 'ok';\n", file_get_contents($this->directory . '/source.php'));
}

/** Runs the real default preset across two checkouts without erasing the first checkout's cache. */
public function testDefaultCachesDoNotInvalidateAnotherCheckout(): void
{
$source = "<?php\n\ndeclare(strict_types=1);\n\nreturn 1;\n";
$this->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', '<?php return \\FastForward\\CodeStandards\\Config\\ECSConfig::configure('
. 'static function (\\Symplify\\EasyCodingStandard\\Configuration\\ECSConfigBuilder $config): void {'
. '$config->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
{
Expand All @@ -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<string> $arguments Explicit CLI arguments.
* @param array<string, string> $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;
Expand Down
Loading