Skip to content
Open
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
53 changes: 51 additions & 2 deletions src/Caching/FileHashComputer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Symplify\EasyCodingStandard\Caching;

use Nette\Utils\Json;
use ReflectionClass;
use Symplify\EasyCodingStandard\Application\Version\StaticVersionResolver;
use Symplify\EasyCodingStandard\Config\ECSConfig;
use Symplify\EasyCodingStandard\DependencyInjection\SimpleParameterProvider;
Expand All @@ -24,8 +25,11 @@ public function computeConfig(string $filePath): string
$ecsConfig = new ECSConfig();
$callable($ecsConfig);

// hash the registered checkers and their configuration
$fileHash = sha1(Json::encode($ecsConfig->getCheckerConfiguration()));
// hash the registered checkers, their configuration and the source of the local ones
$checkerConfiguration = $ecsConfig->getCheckerConfiguration();
$fileHash = sha1(
Json::encode($checkerConfiguration) . $this->hashLocalCheckerFiles(array_keys($checkerConfiguration))
);
Comment on lines +29 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you split this onto more lines? Seems connecting 2 different cases here.


return sha1($fileHash . SimpleParameterProvider::hash() . StaticVersionResolver::PACKAGE_VERSION);
}
Expand All @@ -39,4 +43,49 @@ public function compute(string $filePath): string

return $fileHash;
}

/**
* A checker installed in vendor/ only changes with its package, but a project's own checker can change while its
* class name and configuration stay the same, so its code has to be part of the hash.
*
* @param array<class-string> $checkerClasses
*/
private function hashLocalCheckerFiles(array $checkerClasses): string
{
$fileHashes = [];
foreach ($checkerClasses as $checkerClass) {
foreach ($this->resolveClassFileNames(new ReflectionClass($checkerClass)) as $fileName) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first glance, this seems quite costly to help with cache.
I'd accept some non-reflection approach, like package version.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking. Cost is the right concern, so I measured it before answering.

Hashing the checker list takes 0.27 ms per call (PHP 8.4, average of 200 runs). That's with all 301 classes from PHP-CS-Fixer's built-in set plus one local sniff. It runs once per container boot, for the main process and each parallel worker. The reflection is cheap because ECS has already loaded every checker class by then: new ReflectionClass() only reads metadata already in memory. The only file reads are md5_file() on checkers outside vendor/. Most projects have none of those; projects with custom rules have a handful.

A package version can't cover this case, unfortunately. The checkers it targets live in the project itself, so there is no version to read. That's exactly why their changes are invisible to the cache today.

If you'd still rather avoid reflection, an explicit option would work too. For example, withCache(directory: ..., invalidateOnChangeOf: [__DIR__ . '/utils/ecs']) would hash the files the user lists. It's what we do in our own config in the meantime, through the cache namespace. Happy to switch the PR to that if you prefer it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's quite hard for me to undestand, can you put it in simpler words?

If it only concerns local rules, the native ones starting with Symplify\, code sniffer and php-cs-fixer should be skipped using string compare, not reflection.

if (str_contains($fileName, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)) {
continue;
}

$fileHashes[$fileName] = $this->compute($fileName);
}
}

// contents only, so that the same project checked out elsewhere keeps its cache
$fileHashes = array_values($fileHashes);
sort($fileHashes);

return sha1(implode('', $fileHashes));
}

/**
* @param ReflectionClass<object> $reflectionClass
* @return string[]
*/
private function resolveClassFileNames(ReflectionClass $reflectionClass): array
{
$fileNames = [];
do {
$fileNames[] = $reflectionClass->getFileName();
foreach ($reflectionClass->getTraits() as $traitReflectionClass) {
$fileNames[] = $traitReflectionClass->getFileName();
}

$reflectionClass = $reflectionClass->getParentClass();
} while ($reflectionClass instanceof ReflectionClass);

return array_filter($fileNames, is_string(...));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ public function testInvalidateCacheOnConfigurationChange(): void
$this->filesystem->remove(self::INCLUDED_CONFIG_FILE);
}

public function testInvalidateCacheOnLocalCheckerChange(): void
{
$localSniffFile = __DIR__ . '/Source/LocalSniff.php';
$originalContents = $this->filesystem->readFile($localSniffFile);

$originalHash = $this->fileHashComputer->computeConfig(__DIR__ . '/Source/local_sniff_config.php');

try {
$this->filesystem->dumpFile($localSniffFile, $originalContents . "\n// changed\n");

$changedHash = $this->fileHashComputer->computeConfig(__DIR__ . '/Source/local_sniff_config.php');
} finally {
$this->filesystem->dumpFile($localSniffFile, $originalContents);
}

$this->assertNotSame($originalHash, $changedHash);
}

public function testPhpFileHash(): void
{
$fileOne = __DIR__ . '/Source/SomeScannedClass.php';
Expand Down
20 changes: 20 additions & 0 deletions tests/ChangedFilesDetector/FileHashComputer/Source/LocalSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Symplify\EasyCodingStandard\Tests\ChangedFilesDetector\FileHashComputer\Source;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;

final class LocalSniff implements Sniff
{
public function register()
{
return [T_WHILE];
}

public function process(File $phpcsFile, $stackPtr)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

use Symplify\EasyCodingStandard\Config\ECSConfig;
use Symplify\EasyCodingStandard\Tests\ChangedFilesDetector\FileHashComputer\Source\LocalSniff;

return ECSConfig::configure()
->withRules([LocalSniff::class]);
Loading