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
8 changes: 8 additions & 0 deletions build/ffi-new-not-nullable.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
parameters:
ignoreErrors:
-
# FFI::new() is typed nullable only in the PHP 8.0-8.3 stubs
message: '#^Strict comparison using \=\=\= between FFI\\CData and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: ../src/File/FsEventsFileMonitor.php
4 changes: 4 additions & 0 deletions build/ignore-by-php-version.neon.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
$includes[] = __DIR__ . '/deprecated-8.4.neon';
}

if (PHP_VERSION_ID < 80000 || PHP_VERSION_ID >= 80400) {
$includes[] = __DIR__ . '/ffi-new-not-nullable.neon';
}

if (PHP_VERSION_ID < 80200) {
$includes[] = __DIR__ . '/old-phpunit.neon';
} else {
Expand Down
8 changes: 8 additions & 0 deletions build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ parameters:
- '#^Dynamic call to static method PHPUnit\\Framework\\\S+\(\)\.$#'
- '#should be contravariant with parameter \$node \(PhpParser\\Node\) of method PHPStan\\Rules\\Rule<PhpParser\\Node>::processNode\(\)$#'
- '#Variable property access on PhpParser\\Node#'
-
# FFI's methods are the C functions declared in the cdef string at
# runtime, so no reflection can know them
identifier: method.notFound
message: '#^Call to an undefined method FFI::#'
paths:
- ../src/File/FsEventsFileMonitor.php
- ../src/File/InotifyFileMonitor.php
-
identifier: shipmonk.deadMethod
message: '#^Unused .*?::__construct#' # likely used in DIC
Expand Down
115 changes: 113 additions & 2 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
use function array_unique;
use function array_values;
use function class_exists;
use function clearstatcache;
use function count;
use function error_get_last;
use function explode;
Expand Down Expand Up @@ -120,8 +121,9 @@ final class ResultCacheManager
private const MISSING_FILE_HASH = '';

/**
* Prefixes the recorded hash of an analysed file with a parse error, whose result is not cached.
* The prefixed hash matches no real hash, so the file is re-analysed on every run.
* Prefixes the recorded hash of an analysed file with a parse error, whose result is not cached,
* or of one that changed during the analysis, or uses one that did. The prefixed hash matches no
* real hash, so the file is re-analysed by the next run.
*/
private const REANALYSE_FILE_HASH_PREFIX = 'reanalyse:';

Expand Down Expand Up @@ -173,6 +175,22 @@ final class ResultCacheManager
*/
private array $recordedFileStats = [];

/**
* The stat of the analysed files when they were hashed, to tell at save time which of them
* changed while they were analysed - see getFilesChangedSinceHashed(). Kept as a string rather
* than what stat() returned: it lives through the whole analysis.
*
* @var array<string, string>
*/
private array $hashedFileStats = [];

/**
* The analysed files modified in the second they were hashed in or later.
*
* @var array<string, true>
*/
private array $racilyHashedFiles = [];

private ?ResultCachePathTransformer $pathTransformer = null;

/** @var array<string, true> */
Expand Down Expand Up @@ -1378,6 +1396,37 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
unset($errorsByFile[$file]);
}

// The files changed during the analysis may have been read in another version than the one
// their hash and exported nodes describe, and so may the files using them, which are analysed
// again too. A run that analysed nothing read nothing.
$filesChangedDuringAnalysis = [];
if ($resultCache->isFullAnalysis() || $resultCache->getFilesToAnalyse() !== []) {
$filesChangedDuringAnalysis = $this->getFilesChangedSinceHashed($currentFileHashes);
}
foreach (array_keys($filesChangedDuringAnalysis) as $file) {
if ($output->isVeryVerbose()) {
$output->writeLineFormatted(sprintf('Result cache will re-analyse %s and the files using it because it changed during the analysis.', $file));
}

$filesToReanalyse[$file] = true;
}
if ($filesChangedDuringAnalysis !== []) {
foreach ([$dependencies, $usedTraitDependencies] as $fileDependencies) {
foreach ($fileDependencies as $file => $filesDependedOn) {
if (!array_key_exists($file, $currentFileHashes)) {
continue;
}

foreach ($filesDependedOn as $fileDependedOn) {
if (array_key_exists($fileDependedOn, $filesChangedDuringAnalysis)) {
$filesToReanalyse[$file] = true;
break;
}
}
}
}
}

// in the order save() writes them, which is the order they are restored in
$collectedDataRuleResults = $finalizerResult->getCollectedDataRuleResults();
ksort($collectedDataRuleResults);
Expand All @@ -1392,6 +1441,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
$stubFiles = $this->getStubFiles();
if (
!$resultCache->isFullAnalysis()
&& $filesChangedDuringAnalysis === []
&& array_diff($resultCache->getFilesToAnalyse(), array_keys($filesToReanalyse)) === []
&& $this->restoredCacheUnchanged
&& $errorsByFile === $resultCache->getErrors()
Expand Down Expand Up @@ -3143,6 +3193,17 @@ private function statAnalysedFiles(array $allAnalysedFiles): array
*/
private function hashAnalysedFiles(array $analysedFileStats, ?array $cachedDependencies): array
{
$hashedAt = time();
$this->hashedFileStats = [];
$this->racilyHashedFiles = [];
foreach ($analysedFileStats as $file => $stat) {
$this->hashedFileStats[$file] = self::getStatKey($stat);
if ($stat['mtime'] < $hashedAt && $stat['ctime'] < $hashedAt) {
continue;
}

$this->racilyHashedFiles[$file] = true;
}
$signatures = $this->fileStatSignatures->begin();
$hashes = [];
foreach ($analysedFileStats as $file => $stat) {
Expand Down Expand Up @@ -3197,6 +3258,56 @@ private function fileStatSignaturesDiffer(array $cachedDependencies): bool
return false;
}

/**
* The analysed files written to after they were hashed. The analysis may have read them before
* or after the write, or the files depending on them may have read the other version, so neither
* the hash recorded for them nor their exported nodes describe what was analysed. That includes
* a file changed and changed back, whose hash is the recorded one again.
*
* A file still written to in the second it was hashed in can keep its stat, its hash tells
* whether it is the same - unless it got its hashed contents back within that second as well.
*
* @param array<string, string> $currentFileHashes
* @return array<string, true>
*/
private function getFilesChangedSinceHashed(array $currentFileHashes): array
{
clearstatcache();
$changedFiles = [];
foreach ($this->hashedFileStats as $file => $hashedStat) {
$stat = @stat($file);
if ($stat === false) {
// the next run finds it gone and analyses the files that depended on it
continue;
}

if (self::getStatKey($stat) !== $hashedStat) {
$changedFiles[$file] = true;
continue;
}

if (!array_key_exists($file, $this->racilyHashedFiles)) {
continue;
}

if (!array_key_exists($file, $currentFileHashes) || hash_file('sha256', $file) === $currentFileHashes[$file]) {
continue;
}

$changedFiles[$file] = true;
}

return $changedFiles;
}

/**
* @param array<int|string, int> $stat what stat() returned for the file
*/
private static function getStatKey(array $stat): string
{
return sprintf('%d:%d:%d:%d:%d', $stat['size'], $stat['mtime'], $stat['ctime'], $stat['ino'], $stat['dev']);
}

private function getDependencyFileHash(string $path): string
{
if (!is_file($path)) {
Expand Down
Loading
Loading