From f26be0a2a736c53337d5e0800834689eb9b41b66 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 31 Mar 2026 10:25:10 +0200 Subject: [PATCH 1/2] Add external file dependency tracking for incremental cache invalidation Introduces ExternalFileDependencyRegistrar, a new @api service that allows extensions to declare that an analyzed file depends on an external (non-analyzed) file. When that external file changes, only the dependent analyzed files are re-analyzed instead of triggering full cache invalidation. This is an alternative to ResultCacheMetaExtension for cases where external data changes (e.g. Symfony DI container XML) should not cause full cache invalidation but rather surgical re-analysis of affected files only. The mechanism integrates with the existing result cache system: external dependencies are tracked during analysis, stored in the cache alongside regular file dependencies, and checked during cache restore. Co-Authored-By: Claude Code --- src/Analyser/Analyser.php | 6 ++ src/Analyser/AnalyserResult.php | 10 ++ src/Analyser/AnalyserResultFinalizer.php | 3 + .../ExternalFileDependencyRegistrar.php | 46 +++++++++ src/Analyser/FileAnalyser.php | 2 + src/Analyser/FileAnalyserResult.php | 10 ++ src/Analyser/ResultCache/ResultCache.php | 12 +++ .../ResultCache/ResultCacheManager.php | 96 ++++++++++++++++++- src/Command/AnalyseApplication.php | 2 + src/Command/WorkerCommand.php | 6 ++ src/Parallel/ParallelAnalyser.php | 14 ++- src/Testing/RuleTestCase.php | 2 + tests/PHPStan/Analyser/AnalyserTest.php | 2 + 13 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 src/Analyser/ExternalFileDependencyRegistrar.php diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index b4928e66550..d62081ca272 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -75,6 +75,7 @@ public function analyse( $dependencies = []; $usedTraitDependencies = []; $exportedNodes = []; + $externalFileDependencies = []; foreach ($files as $file) { if ($preFileCallback !== null) { $preFileCallback($file); @@ -99,6 +100,10 @@ public function analyse( $collectedData = array_merge($collectedData, $fileAnalyserResult->getCollectedData()); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $fileExternalDeps = $fileAnalyserResult->getExternalFileDependencies(); + if (count($fileExternalDeps) > 0) { + $externalFileDependencies[$file] = $fileExternalDeps; + } $fileExportedNodes = $fileAnalyserResult->getExportedNodes(); if (count($fileExportedNodes) > 0) { @@ -143,6 +148,7 @@ public function analyse( exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, peakMemoryUsageBytes: memory_get_peak_usage(true), + externalFileDependencies: $internalErrorsCount === 0 ? $externalFileDependencies : null, ); } diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php index 576471d1ff3..a61263f491f 100644 --- a/src/Analyser/AnalyserResult.php +++ b/src/Analyser/AnalyserResult.php @@ -28,6 +28,7 @@ final class AnalyserResult * @param array>|null $dependencies * @param array>|null $usedTraitDependencies * @param array> $exportedNodes + * @param array>|null $externalFileDependencies */ public function __construct( private array $unorderedErrors, @@ -43,6 +44,7 @@ public function __construct( private array $exportedNodes, private bool $reachedInternalErrorsCountLimit, private int $peakMemoryUsageBytes, + private ?array $externalFileDependencies = null, ) { } @@ -159,6 +161,14 @@ public function getExportedNodes(): array return $this->exportedNodes; } + /** + * @return array>|null + */ + public function getExternalFileDependencies(): ?array + { + return $this->externalFileDependencies; + } + public function hasReachedInternalErrorsCountLimit(): bool { return $this->reachedInternalErrorsCountLimit; diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index e4d5cbd7587..539c8dd2f18 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -148,6 +148,7 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), + externalFileDependencies: $analyserResult->getExternalFileDependencies(), ), $collectorErrors, $locallyIgnoredCollectorErrors); } @@ -167,6 +168,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), + externalFileDependencies: $analyserResult->getExternalFileDependencies(), ); } @@ -231,6 +233,7 @@ private function addUnmatchedIgnoredErrors( exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), + externalFileDependencies: $analyserResult->getExternalFileDependencies(), ), $collectorErrors, $locallyIgnoredCollectorErrors, diff --git a/src/Analyser/ExternalFileDependencyRegistrar.php b/src/Analyser/ExternalFileDependencyRegistrar.php new file mode 100644 index 00000000000..1ec3526e0ce --- /dev/null +++ b/src/Analyser/ExternalFileDependencyRegistrar.php @@ -0,0 +1,46 @@ + */ + private array $currentFileDependencies = []; + + /** + * Register a dependency on an external file for the currently analyzed file. + */ + public function add(string $externalFilePath): void + { + $this->currentFileDependencies[] = $externalFilePath; + } + + /** + * @return list + * @internal Used by FileAnalyser after each file analysis + */ + public function getAndReset(): array + { + $deps = array_values(array_unique($this->currentFileDependencies)); + $this->currentFileDependencies = []; + + return $deps; + } + +} diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php index c59e3545fb2..d292dfd8ed6 100644 --- a/src/Analyser/FileAnalyser.php +++ b/src/Analyser/FileAnalyser.php @@ -60,6 +60,7 @@ public function __construct( private IgnoreErrorExtensionProvider $ignoreErrorExtensionProvider, private RuleErrorTransformer $ruleErrorTransformer, private LocalIgnoresProcessor $localIgnoresProcessor, + private ExternalFileDependencyRegistrar $externalFileDependencyRegistrar, #[AutowiredParameter] private bool $reportIgnoresWithoutComments, ) @@ -247,6 +248,7 @@ public function analyseFile( $linesToIgnore, $unmatchedLineIgnores, $processedFiles, + $this->externalFileDependencyRegistrar->getAndReset(), ); } diff --git a/src/Analyser/FileAnalyserResult.php b/src/Analyser/FileAnalyserResult.php index 320b736c6b7..933f19c7356 100644 --- a/src/Analyser/FileAnalyserResult.php +++ b/src/Analyser/FileAnalyserResult.php @@ -25,6 +25,7 @@ final class FileAnalyserResult * @param LinesToIgnore $linesToIgnore * @param LinesToIgnore $unmatchedLineIgnores * @param list $processedFiles + * @param list $externalFileDependencies */ public function __construct( private array $errors, @@ -38,6 +39,7 @@ public function __construct( private array $linesToIgnore, private array $unmatchedLineIgnores, private array $processedFiles, + private array $externalFileDependencies = [], ) { } @@ -130,4 +132,12 @@ public function getProcessedFiles(): array return $this->processedFiles; } + /** + * @return list + */ + public function getExternalFileDependencies(): array + { + return $this->externalFileDependencies; + } + } diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index 5cfefc6f46a..85a9ddcaaac 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -27,6 +27,7 @@ final class ResultCache * @param array> $exportedNodes * @param array $projectExtensionFiles * @param array $currentFileHashes + * @param array> $externalFileDependencies */ public function __construct( private array $filesToAnalyse, @@ -43,6 +44,7 @@ public function __construct( private array $exportedNodes, private array $projectExtensionFiles, private array $currentFileHashes, + private array $externalFileDependencies = [], ) { } @@ -153,4 +155,14 @@ public function getCurrentFileHashes(): array return $this->currentFileHashes; } + /** + * Inverted external file dependencies: external file => dependent analyzed files. + * + * @return array> + */ + public function getExternalFileDependencies(): array + { + return $this->externalFileDependencies; + } + } diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 3b929cc59ee..d7ef6ad9db6 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -356,6 +356,22 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $filesToAnalyse = []; $invertedDependenciesToReturn = []; $invertedUsedTraitDependenciesToReturn = []; + + // Check external file dependencies for incremental re-analysis + $cachedExternalDependencies = $data['externalDependencies'] ?? []; + $externalDependenciesToReturn = $cachedExternalDependencies; + foreach ($cachedExternalDependencies as $externalFile => $externalData) { + if (!is_file($externalFile) || $this->getFileHash($externalFile) !== $externalData['fileHash']) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('External file %s changed, re-analysing dependent files.', $externalFile)); + } + foreach ($externalData['dependentFiles'] as $dependentFile) { + if (is_file($dependentFile)) { + $filesToAnalyse[] = $dependentFile; + } + } + } + } $errors = $data['errorsCallback'](); $locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback'](); $linesToIgnore = $data['linesToIgnore']; @@ -515,6 +531,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? exportedNodes: $filteredExportedNodes, projectExtensionFiles: $data['projectExtensionFiles'], currentFileHashes: $currentFileHashes, + externalFileDependencies: $externalDependenciesToReturn, ); } @@ -624,7 +641,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache if ($projectConfigArray !== null) { $meta['projectConfig'] = Neon::encode($projectConfigArray); } - $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, array $exportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { + $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, array $exportedNodes, array $projectExtensionFiles, array $externalFileDependencies = []) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { if ($onlyFiles) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.'); @@ -672,7 +689,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } } - $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta); + $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $externalFileDependencies); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -688,7 +705,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache if ($analyserResult->getDependencies() !== null) { $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); } - $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getExportedNodes(), $projectExtensionFiles); + $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getExportedNodes(), $projectExtensionFiles, $analyserResult->getExternalFileDependencies() ?? []); } else { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because it was not requested.'); @@ -706,6 +723,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $exportedNodes = $this->mergeExportedNodes($resultCache, $analyserResult->getExportedNodes()); $linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore()); $unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores()); + $externalFileDependencies = $this->mergeExternalFileDependencies($resultCache->getExternalFileDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getExternalFileDependencies()); $saved = false; if ($save !== false) { @@ -729,7 +747,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectExtensionFiles[$file] = [$hash, true, $className]; } } - $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles); + $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles, $externalFileDependencies); } $flatErrors = []; @@ -760,6 +778,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), + externalFileDependencies: $externalFileDependencies !== [] ? $externalFileDependencies : null, ), $saved); } @@ -944,6 +963,45 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres return $newUnmatchedLineIgnores; } + /** + * Merges cached inverted external dependencies with fresh analysis results. + * + * @param array> $cachedExternalDependencies Inverted: external file => dependent analyzed files + * @param string[] $filesToAnalyse Files that were re-analyzed + * @param array>|null $freshExternalDependencies Non-inverted: analyzed file => external files + * @return array> Non-inverted: analyzed file => external files + */ + private function mergeExternalFileDependencies( + array $cachedExternalDependencies, + array $filesToAnalyse, + ?array $freshExternalDependencies, + ): array + { + if ($freshExternalDependencies === null) { + return []; + } + + // Un-invert cached external dependencies: external file => [dependents] → dependent => [external files] + $cachedPerFile = []; + foreach ($cachedExternalDependencies as $externalFile => $externalData) { + $dependentFiles = $externalData['dependentFiles'] ?? $externalData; + foreach ($dependentFiles as $dependentFile) { + $cachedPerFile[$dependentFile][] = $externalFile; + } + } + + // Replace re-analyzed files with fresh data + $merged = $cachedPerFile; + foreach ($filesToAnalyse as $file) { + unset($merged[$file]); + if (array_key_exists($file, $freshExternalDependencies)) { + $merged[$file] = $freshExternalDependencies[$file]; + } + } + + return $merged; + } + /** * @param array> $errors * @param array> $locallyIgnoredErrors @@ -956,6 +1014,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array $projectExtensionFiles * @param array $currentFileHashes * @param mixed[] $meta + * @param array> $externalFileDependencies */ private function save( int $lastFullAnalysisTime, @@ -970,6 +1029,7 @@ private function save( array $projectExtensionFiles, array $currentFileHashes, array $meta, + array $externalFileDependencies = [], ): void { $invertedDependencies = []; @@ -1043,6 +1103,31 @@ private function save( ksort($exportedNodes); + // Build inverted external dependencies: external file => {hash, dependentFiles} + $invertedExternalDependencies = []; + foreach ($externalFileDependencies as $analysedFile => $externalFiles) { + foreach ($externalFiles as $externalFile) { + if (!array_key_exists($externalFile, $invertedExternalDependencies)) { + if (!is_file($externalFile)) { + continue; + } + $invertedExternalDependencies[$externalFile] = [ + 'fileHash' => $this->getFileHash($externalFile), + 'dependentFiles' => [], + ]; + } + $invertedExternalDependencies[$externalFile]['dependentFiles'][] = $analysedFile; + } + } + + foreach ($invertedExternalDependencies as $externalFile => $externalData) { + $dependentFiles = array_values(array_unique($externalData['dependentFiles'])); + sort($dependentFiles); + $invertedExternalDependencies[$externalFile]['dependentFiles'] = $dependentFiles; + } + + ksort($invertedExternalDependencies); + $file = $this->cacheFilePath; FileWriter::write( @@ -1059,7 +1144,8 @@ private function save( 'unmatchedLineIgnores' => " . var_export($unmatchedLineIgnores, true) . ", 'collectedDataCallback' => static function (): array { return " . var_export($collectedData, true) . "; }, 'dependencies' => " . var_export($invertedDependencies, true) . ", - 'exportedNodesCallback' => static function (): array { return " . var_export($exportedNodes, true) . '; }, + 'exportedNodesCallback' => static function (): array { return " . var_export($exportedNodes, true) . "; }, + 'externalDependencies' => " . var_export($invertedExternalDependencies, true) . ', ]; ', ); diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index 3b6d5842b4f..62bc508d332 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -121,6 +121,7 @@ public function analyse( exportedNodes: $intermediateAnalyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $intermediateAnalyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $intermediateAnalyserResult->getPeakMemoryUsageBytes(), + externalFileDependencies: $intermediateAnalyserResult->getExternalFileDependencies(), ); } @@ -346,6 +347,7 @@ private function switchTmpFileInAnalyserResult( exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), + externalFileDependencies: $analyserResult->getExternalFileDependencies(), ); } diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 2a221f6e846..30e6b8d4877 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -228,6 +228,7 @@ private function runWorker( $dependencies = []; $usedTraitDependencies = []; $exportedNodes = []; + $externalFileDependencies = []; foreach ($files as $file) { try { if ($file === $insteadOfFile) { @@ -242,6 +243,10 @@ private function runWorker( $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); $exportedNodes[$file] = $fileAnalyserResult->getExportedNodes(); + $fileExternalDeps = $fileAnalyserResult->getExternalFileDependencies(); + if (count($fileExternalDeps) > 0) { + $externalFileDependencies[$file] = $fileExternalDeps; + } foreach ($fileErrors as $fileError) { $errors[] = $fileError; } @@ -282,6 +287,7 @@ private function runWorker( 'dependencies' => $dependencies, 'usedTraitDependencies' => $usedTraitDependencies, 'exportedNodes' => $exportedNodes, + 'externalFileDependencies' => $externalFileDependencies, 'files' => $files, 'internalErrorsCount' => $internalErrorsCount, ]]); diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index 7792a0cb6ba..9dcd9c16963 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -92,12 +92,13 @@ public function analyse( $usedTraitDependencies = []; $reachedInternalErrorsCountLimit = false; $exportedNodes = []; + $externalFileDependencies = []; /** @var Deferred $deferred */ $deferred = new Deferred(); $server = new TcpServer('127.0.0.1:0', $loop); - $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$exportedNodes, &$peakMemoryUsages): void { + $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$exportedNodes, &$externalFileDependencies, &$peakMemoryUsages): void { if (count($jobs) > 0 && $internalErrorsCount === 0) { $internalErrors[] = new InternalError( 'Some parallel worker jobs have not finished.', @@ -123,6 +124,7 @@ public function analyse( exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, peakMemoryUsageBytes: array_sum($peakMemoryUsages), // not 100% correct as the peak usages of workers might not have met + externalFileDependencies: $internalErrorsCount === 0 ? $externalFileDependencies : null, )); }); $server->on('connection', function (ConnectionInterface $connection) use (&$jobs): void { @@ -194,7 +196,7 @@ public function analyse( $commandOptions, $input, ), $loop, $this->processTimeout); - $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler): void { + $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$exportedNodes, &$externalFileDependencies, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler): void { $fileErrors = []; foreach ($json['errors'] as $jsonError) { $fileErrors[] = Error::decode($jsonError); @@ -252,6 +254,14 @@ public function analyse( $usedTraitDependencies[$file] = $fileUsedTraitDependencies; } + /** + * @var string $file + * @var list $fileExternalDeps + */ + foreach ($json['externalFileDependencies'] ?? [] as $file => $fileExternalDeps) { + $externalFileDependencies[$file] = $fileExternalDeps; + } + foreach ($json['linesToIgnore'] as $file => $fileLinesToIgnore) { if (count($fileLinesToIgnore) === 0) { continue; diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 0565407424e..42c23625dd4 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Analyser; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; +use PHPStan\Analyser\ExternalFileDependencyRegistrar; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\FileAnalyser; use PHPStan\Analyser\IgnoreErrorExtensionProvider; @@ -137,6 +138,7 @@ private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser new IgnoreErrorExtensionProvider(self::getContainer()), self::getContainer()->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), + new ExternalFileDependencyRegistrar(), false, ); $this->analyser = new Analyser( diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 27f9daddb07..a97212fe916 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -6,6 +6,7 @@ use PhpParser\Lexer; use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser\Php7; +use PHPStan\Analyser\ExternalFileDependencyRegistrar; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\Ignore\IgnoreLexer; use PHPStan\Collectors\Registry as CollectorRegistry; @@ -847,6 +848,7 @@ private function createAnalyser(): Analyser new IgnoreErrorExtensionProvider(new NetteContainer(new Container([]))), $container->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), + new ExternalFileDependencyRegistrar(), false, ); From fe772e5a3a4c37fd44ce972f6f9dded20536bf0a Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 31 Mar 2026 10:50:17 +0200 Subject: [PATCH 2/2] Fix CI: coding standard violations and PHPStan self-analysis error - Use early exit (continue) instead of nested if blocks in ResultCacheManager - Add missing `use function count` in WorkerCommand - Remove same-namespace import in AnalyserTest - Fix mergeExternalFileDependencies: strip hashes from cached data in restore() before passing to ResultCache, matching the pattern used by regular dependencies Co-Authored-By: Claude Code --- .../ResultCache/ResultCacheManager.php | 33 +++++++++++-------- src/Command/WorkerCommand.php | 1 + tests/PHPStan/Analyser/AnalyserTest.php | 1 - 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index d7ef6ad9db6..feb66e69962 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -358,18 +358,24 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $invertedUsedTraitDependenciesToReturn = []; // Check external file dependencies for incremental re-analysis + /** @var array}> $cachedExternalDependencies */ $cachedExternalDependencies = $data['externalDependencies'] ?? []; - $externalDependenciesToReturn = $cachedExternalDependencies; + $externalDependenciesToReturn = []; foreach ($cachedExternalDependencies as $externalFile => $externalData) { - if (!is_file($externalFile) || $this->getFileHash($externalFile) !== $externalData['fileHash']) { - if ($output->isVeryVerbose()) { - $output->writeLineFormatted(sprintf('External file %s changed, re-analysing dependent files.', $externalFile)); - } - foreach ($externalData['dependentFiles'] as $dependentFile) { - if (is_file($dependentFile)) { - $filesToAnalyse[] = $dependentFile; - } + $externalDependenciesToReturn[$externalFile] = $externalData['dependentFiles']; + if (is_file($externalFile) && $this->getFileHash($externalFile) === $externalData['fileHash']) { + continue; + } + + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('External file %s changed, re-analysing dependent files.', $externalFile)); + } + foreach ($externalData['dependentFiles'] as $dependentFile) { + if (!is_file($dependentFile)) { + continue; } + + $filesToAnalyse[] = $dependentFile; } } $errors = $data['errorsCallback'](); @@ -983,8 +989,7 @@ private function mergeExternalFileDependencies( // Un-invert cached external dependencies: external file => [dependents] → dependent => [external files] $cachedPerFile = []; - foreach ($cachedExternalDependencies as $externalFile => $externalData) { - $dependentFiles = $externalData['dependentFiles'] ?? $externalData; + foreach ($cachedExternalDependencies as $externalFile => $dependentFiles) { foreach ($dependentFiles as $dependentFile) { $cachedPerFile[$dependentFile][] = $externalFile; } @@ -994,9 +999,11 @@ private function mergeExternalFileDependencies( $merged = $cachedPerFile; foreach ($filesToAnalyse as $file) { unset($merged[$file]); - if (array_key_exists($file, $freshExternalDependencies)) { - $merged[$file] = $freshExternalDependencies[$file]; + if (!array_key_exists($file, $freshExternalDependencies)) { + continue; } + + $merged[$file] = $freshExternalDependencies[$file]; } return $merged; diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 30e6b8d4877..88b1c8e7a82 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -29,6 +29,7 @@ use function array_merge; use function array_unshift; use function array_values; +use function count; use function defined; use function is_array; use function is_bool; diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index a97212fe916..13bbc4a94e3 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -6,7 +6,6 @@ use PhpParser\Lexer; use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser\Php7; -use PHPStan\Analyser\ExternalFileDependencyRegistrar; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\Ignore\IgnoreLexer; use PHPStan\Collectors\Registry as CollectorRegistry;