From 404038818f05a5f048c317dc0f8a0baa61b56203 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 13 Sep 2026 15:58:50 +0900 Subject: [PATCH 1/3] Skip rewriting unchanged result cache Co-authored-by: Cursor --- .../ResultCache/ResultCacheManager.php | 47 +++++++++++++++++-- .../Command/ResultCacheInfoCommandTest.php | 30 ++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 88b560b1d7d..5b45d1e5c69 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -151,6 +151,11 @@ final class ResultCacheManager /** @var array */ private array $alreadyProcessed = []; + private bool $restoredCacheUnchanged = false; + + /** @var array */ + private array $restoredStubFiles = []; + /** * @param string[] $analysedPaths * @param string[] $analysedPathsFromConfig @@ -268,6 +273,9 @@ private function fullAnalysis( */ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?array $projectConfigArray, Output $output): ResultCache { + $this->restoredCacheUnchanged = false; + $this->restoredStubFiles = []; + $startTime = microtime(true); $currentFileHashes = []; foreach ($allAnalysedFiles as $analysedFile) { @@ -384,7 +392,8 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? // make anything be re-analysed. A scanned file can change without any of its symbols changing, // and then nothing here has to happen at all. $scannedFilesWithChangedSymbols = []; - if ($this->isMetaDifferent($data['meta'], $meta)) { + $metaDifferent = $this->isMetaDifferent($data['meta'], $meta); + if ($metaDifferent) { $diffs = $this->getMetaKeyDifferences($data['meta'], $meta); // Some metadata differences do not invalidate the whole analysis, because the code they @@ -631,6 +640,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $filteredCollectedData = []; $filteredExportedNodes = []; $newFileAppeared = false; + $dependencyFilesChanged = false; foreach (array_keys($cachedStubFiles) as $stubFile) { if (!array_key_exists($stubFile, $errors)) { @@ -748,6 +758,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } if (is_file($notAnalysedFile) && $wasMissing) { + $dependencyFilesChanged = true; // It exists now. Whether it holds any symbol is beside the point - a file that was named // and was not there is now there, and that alone changes what the analysis says. $invertedDependenciesToReturn[$notAnalysedFile] = $dependentFiles; @@ -773,6 +784,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? continue; } + $dependencyFilesChanged = true; // Edited: the same rule as for an analysed file. Nothing the files depending on it can // see changed unless its exported nodes did, so a body-only edit re-analyses nothing - // except the classes using a trait declared here, whose body is analysed in their @@ -810,6 +822,8 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } $dependentFiles = array_merge($dependentFiles, $usedTraitDependentFiles); + } else { + $dependencyFilesChanged = true; } foreach ($dependentFiles as $dependentFile) { @@ -857,6 +871,9 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? )); } + $this->restoredCacheUnchanged = !$metaDifferent && !$dependencyFilesChanged; + $this->restoredStubFiles = $cachedStubFiles; + return new ResultCache( filesToAnalyse: $filesToAnalyse, fullAnalysis: false, @@ -1059,7 +1076,29 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } } - $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta); + $stubFiles = $this->getStubFiles(); + if ( + !$resultCache->isFullAnalysis() + && $resultCache->getFilesToAnalyse() === [] + && $this->restoredCacheUnchanged + && $errorsByFile === $resultCache->getErrors() + && $locallyIgnoredErrorsByFile === $resultCache->getLocallyIgnoredErrors() + && $linesToIgnore === $resultCache->getLinesToIgnore() + && $unmatchedLineIgnores === $resultCache->getUnmatchedLineIgnores() + && $collectedDataByFile === $resultCache->getCollectedData() + && $packageDependencies === $resultCache->getPackageDependencies() + && $exportedNodes === $resultCache->getExportedNodes() + && $projectExtensionFiles === $resultCache->getProjectExtensionFiles() + && $stubFiles === $this->restoredStubFiles + ) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not rewritten because it is unchanged.'); + } + + return true; + } + + $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $stubFiles); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -1385,6 +1424,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array $projectExtensionFiles * @param array $currentFileHashes * @param mixed[] $meta + * @param array $stubFiles */ private function save( int $lastFullAnalysisTime, @@ -1400,6 +1440,7 @@ private function save( array $projectExtensionFiles, array $currentFileHashes, array $meta, + array $stubFiles, ): void { $invertedDependencies = []; @@ -1476,7 +1517,7 @@ private function save( // The only point where the StubFilesExtensions may run: the analysis is over, bootstrapFiles // have been executed, so the extensions can rely on them. restore() reads these hashes back // instead of running the extensions again. - $meta['stubFiles'] = $this->getStubFiles(); + $meta['stubFiles'] = $stubFiles; // Store paths relative to the anchor so the cache survives a change of the project's absolute // path prefix (a fresh CI checkout dir, a git worktree). projectConfig inside $meta is already a diff --git a/tests/PHPStan/Command/ResultCacheInfoCommandTest.php b/tests/PHPStan/Command/ResultCacheInfoCommandTest.php index 56cf71cf0fd..2a6dcffc308 100644 --- a/tests/PHPStan/Command/ResultCacheInfoCommandTest.php +++ b/tests/PHPStan/Command/ResultCacheInfoCommandTest.php @@ -10,12 +10,15 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use function array_map; +use function clearstatcache; use function escapeshellarg; use function exec; +use function filemtime; use function implode; use function md5; use function sprintf; use function sys_get_temp_dir; +use function touch; use function uniqid; use const PHP_BINARY; @@ -141,6 +144,33 @@ public function testChangedFileIsCountedAsFileToAnalyse(): void $this->assertSame(1, $json['filesToAnalyseCount']); } + public function testWarmAnalysisDoesNotRewriteUnchangedResultCache(): void + { + [$analyseOutput, $analyseExitCode] = $this->runPhpstan(['analyse', '--no-progress']); + $this->assertSame(0, $analyseExitCode, $analyseOutput); + + $resultCachePath = $this->projectDir . '/tmp/resultCache.php'; + $oldModificationTime = 946684800; + $this->assertTrue(touch($resultCachePath, $oldModificationTime)); + + [$warmOutput, $warmExitCode] = $this->runPhpstan(['analyse', '--no-progress', '-vvv']); + $this->assertSame(0, $warmExitCode, $warmOutput); + $this->assertStringContainsString('Result cache was not rewritten because it is unchanged.', $warmOutput); + clearstatcache(true, $resultCachePath); + $this->assertSame($oldModificationTime, filemtime($resultCachePath)); + + FileSystem::write( + $this->projectDir . '/src/Foo.php', + FileSystem::read($this->projectDir . '/src/Foo.php') . "\n", + ); + + [$changedOutput, $changedExitCode] = $this->runPhpstan(['analyse', '--no-progress', '-vvv']); + $this->assertSame(0, $changedExitCode, $changedOutput); + $this->assertStringContainsString('Result cache is saved.', $changedOutput); + clearstatcache(true, $resultCachePath); + $this->assertNotSame($oldModificationTime, filemtime($resultCachePath)); + } + public function testChangedLevelInvalidatesResultCache(): void { [$analyseOutput, $analyseExitCode] = $this->runPhpstan(['analyse', '--no-progress']); From b0d397fc812709cc33c312faa7fe44096d2bd562 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 13 Sep 2026 16:17:03 +0900 Subject: [PATCH 2/3] fixup! Skip rewriting unchanged result cache Co-authored-by: Cursor --- .github/workflows/e2e-tests.yml | 11 +++++++++-- src/Analyser/ResultCache/ResultCacheManager.php | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ae29efb84c7..7984ff6cd29 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -128,10 +128,17 @@ jobs: cd e2e/result-cache-atomic-save ../../bin/phpstan INODE=$(ls -i tmp/resultCache.php | awk '{print $1}') + OUTPUT=$(../../bin/phpstan -vvv 2>&1) + echo "$OUTPUT" + UNCHANGED=$([ "$INODE" = "$(ls -i tmp/resultCache.php | awk '{print $1}')" ] && echo unchanged || echo replaced) + ../bashunit -a equals 'unchanged' "$UNCHANGED" + ../bashunit -a contains 'Result cache was not rewritten because it is unchanged.' "$OUTPUT" + echo >> src/Foo.php ../../bin/phpstan # The cache is written next to its final path and renamed into place, so saving it - # again replaces the file rather than truncating and rewriting the one the next run - # reads. That is what keeps a run killed mid-save from leaving a partial cache behind. + # after a change replaces the file rather than truncating and rewriting the one the + # next run reads. That is what keeps a run killed mid-save from leaving a partial + # cache behind. REPLACED=$([ "$INODE" != "$(ls -i tmp/resultCache.php | awk '{print $1}')" ] && echo replaced || echo 'written in place') ../bashunit -a equals 'replaced' "$REPLACED" # A completed save leaves nothing next to the cache file. diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 5b45d1e5c69..e0a7278e4f6 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -1090,6 +1090,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache && $exportedNodes === $resultCache->getExportedNodes() && $projectExtensionFiles === $resultCache->getProjectExtensionFiles() && $stubFiles === $this->restoredStubFiles + && is_file($this->cacheFilePath) ) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not rewritten because it is unchanged.'); From 6a243c4af91a4921ca59794c89ee862edbdb0c4c Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 13 Sep 2026 17:45:10 +0900 Subject: [PATCH 3/3] fixup! Skip rewriting unchanged result cache Co-authored-by: Cursor --- .github/workflows/e2e-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7984ff6cd29..cd215ee7fa3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -380,11 +380,13 @@ jobs: echo "$OUTPUT" ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" ../bashunit -a not_contains 'Scanned files with changed symbols' "$OUTPUT" + ../bashunit -a contains 'Result cache is saved.' "$OUTPUT" # The new contents are recorded: the next run has nothing to notice. OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" ../bashunit -a not_contains 'Scanned files with changed symbols' "$OUTPUT" ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'Result cache was not rewritten because it is unchanged.' "$OUTPUT" # A changed return type is an exported node change: the file depending on it is # re-analysed. A changed signature cannot make a new symbol available, so the file with # the error is left alone.