From 8101b09b9131273f1bac9c5637c75d3d603edf79 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 25 Jun 2026 10:13:26 +0200 Subject: [PATCH] Invalidate only package-dependent files when a Composer package changes Today any change to composer.lock or installed.json marks the whole result cache as outdated and forces a full re-analysis, even when the bumped package is a dev tool or a transitive dependency that no analysed file uses. This records, per file, the project-vendor packages whose code the file reflected during analysis. The edges reuse the reflection dependencies NodeDependencies already collects for file-to-file incremental invalidation (instead of discarding the vendor entries at NodeDependencies::getFileDependencies), so the no-under-invalidation property is inherited: if a file's analysis reflected a package's code, the file is recorded against that package. Packages are resolved through the project's installed.php install paths and scoped to the analysed project's vendor, so PHPStan's own bundled dependencies (already covered by CACHE_VERSION and the PHP version) are excluded. When the only meta difference is composerLocks/composerInstalled, the result cache now diffs the per-package versions, seeds only the files that depend on a changed package, and falls through into the existing incremental loop, which still adds content-changed files and propagates dependents on a signature change. Any other meta difference still triggers a full re-analysis, and an unreadable or unexpectedly shaped installed.php falls back to the full nuke, so the change never under-invalidates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 2 +- conf/services.neon | 5 + src/Analyser/Analyser.php | 3 + src/Analyser/AnalyserResult.php | 10 ++ src/Analyser/AnalyserResultFinalizer.php | 3 + src/Analyser/FileAnalyser.php | 6 + src/Analyser/FileAnalyserCallback.php | 19 ++ src/Analyser/FileAnalyserResult.php | 10 ++ src/Analyser/ResultCache/ResultCache.php | 10 ++ .../ResultCache/ResultCacheManager.php | 154 ++++++++++++++--- src/Command/AnalyseApplication.php | 7 + src/Command/AnalyserRunner.php | 1 + src/Command/FixerWorkerRunner.php | 1 + src/Dependency/NodeDependencies.php | 40 +++++ src/Dependency/PackageDependencyResolver.php | 162 ++++++++++++++++++ src/Parallel/ParallelAnalyser.php | 14 +- src/Parallel/WorkerRunner.php | 4 + src/Testing/RuleTestCase.php | 2 + tests/PHPStan/Analyser/AnalyserTest.php | 2 + .../PackageDependencyResolverTest.php | 91 ++++++++++ .../data/package-resolver/composer.json | 1 + .../vendor/composer/installed.php | 24 +++ 22 files changed, 545 insertions(+), 26 deletions(-) create mode 100644 src/Dependency/PackageDependencyResolver.php create mode 100644 tests/PHPStan/Dependency/PackageDependencyResolverTest.php create mode 100644 tests/PHPStan/Dependency/data/package-resolver/composer.json create mode 100644 tests/PHPStan/Dependency/data/package-resolver/vendor/composer/installed.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6753593ba47..208bb0e5069 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -189,7 +189,7 @@ jobs: patch -b src/Generated/Foo.php < patch.patch OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" - ../bashunit -a contains 'Result cache not used because the metadata do not match: projectConfig, scannedFiles' "$OUTPUT" + ../bashunit -a contains 'Result cache not used because the metadata do not match: scannedFiles' "$OUTPUT" ../bashunit -a contains 'Instantiated class ResultCacheE2EGenerated\Foo not found.' "$OUTPUT" - script: | cd e2e/result-cache-traits diff --git a/conf/services.neon b/conf/services.neon index 948baee6a76..df72e9f2b6a 100644 --- a/conf/services.neon +++ b/conf/services.neon @@ -56,6 +56,11 @@ services: - class: PHPStan\Reflection\BetterReflection\SourceLocator\CachingVisitor + - + class: PHPStan\Dependency\PackageDependencyResolver + arguments: + composerAutoloaderProjectPaths: %composerAutoloaderProjectPaths% + - class: PHPStan\Reflection\Php\PhpClassReflectionExtension arguments: diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 475781e17b6..9b4e4a7460f 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -74,6 +74,7 @@ public function analyse( $reachedInternalErrorsCountLimit = false; $dependencies = []; $usedTraitDependencies = []; + $packageDependencies = []; $exportedNodes = []; $allProcessedFiles = []; foreach ($files as $file) { @@ -101,6 +102,7 @@ public function analyse( $collectedData = array_merge($collectedData, $fileAnalyserResult->getCollectedData()); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $fileExportedNodes = $fileAnalyserResult->getExportedNodes(); if (count($fileExportedNodes) > 0) { @@ -142,6 +144,7 @@ public function analyse( collectedData: $collectedData, dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, + packageDependencies: $internalErrorsCount === 0 ? $packageDependencies : null, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, peakMemoryUsageBytes: memory_get_peak_usage(true), diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php index 31b88e27300..f9cb1bdef66 100644 --- a/src/Analyser/AnalyserResult.php +++ b/src/Analyser/AnalyserResult.php @@ -27,6 +27,7 @@ final class AnalyserResult * @param list $internalErrors * @param array>|null $dependencies * @param array>|null $usedTraitDependencies + * @param array>|null $packageDependencies * @param array> $exportedNodes * @param list $processedFiles */ @@ -41,6 +42,7 @@ public function __construct( private array $collectedData, private ?array $dependencies, private ?array $usedTraitDependencies, + private ?array $packageDependencies, private array $exportedNodes, private bool $reachedInternalErrorsCountLimit, private int $peakMemoryUsageBytes, @@ -153,6 +155,14 @@ public function getUsedTraitDependencies(): ?array return $this->usedTraitDependencies; } + /** + * @return array>|null + */ + public function getPackageDependencies(): ?array + { + return $this->packageDependencies; + } + /** * @return array> */ diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index 34efa63e7b8..cc06cb3eee3 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -145,6 +145,7 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), @@ -165,6 +166,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), @@ -230,6 +232,7 @@ private function addUnmatchedIgnoredErrors( collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php index c59e3545fb2..1870b581a69 100644 --- a/src/Analyser/FileAnalyser.php +++ b/src/Analyser/FileAnalyser.php @@ -10,6 +10,7 @@ use PHPStan\Collectors\CollectedData; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; +use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\FileNode; @@ -57,6 +58,7 @@ public function __construct( #[AutowiredParameter(ref: '@defaultAnalysisParser')] private Parser $parser, private DependencyResolver $dependencyResolver, + private PackageDependencyResolver $packageDependencyResolver, private IgnoreErrorExtensionProvider $ignoreErrorExtensionProvider, private RuleErrorTransformer $ruleErrorTransformer, private LocalIgnoresProcessor $localIgnoresProcessor, @@ -92,6 +94,7 @@ public function analyseFile( $fileDependencies = []; $usedTraitFileDependencies = []; + $filePackageDependencies = []; $exportedNodes = []; $linesToIgnore = []; $unmatchedLineIgnores = []; @@ -111,6 +114,7 @@ public function analyseFile( $this->ignoreErrorExtensionProvider->getExtensions(), $this->parser, $this->dependencyResolver, + $this->packageDependencyResolver, $this->ruleErrorTransformer, $processedFiles, ); @@ -125,6 +129,7 @@ public function analyseFile( $fileCollectedData = $nodeCallback->getFileCollectedData(); $fileDependencies = $nodeCallback->getFileDependencies(); $usedTraitFileDependencies = $nodeCallback->getUsedTraitFileDependencies(); + $filePackageDependencies = $nodeCallback->getPackageDependencies(); $exportedNodes = $nodeCallback->getExportedNodes(); $linesToIgnore = $nodeCallback->getLinesToIgnore(); $unmatchedLineIgnores = $nodeCallback->getUnmatchedLineIgnores(); @@ -243,6 +248,7 @@ public function analyseFile( $fileCollectedData, array_values(array_unique($fileDependencies)), array_values(array_unique($usedTraitFileDependencies)), + array_values(array_unique($filePackageDependencies)), $exportedNodes, $linesToIgnore, $unmatchedLineIgnores, diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index 07e5371a04e..566390a15bb 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -10,6 +10,7 @@ use PHPStan\Collectors\CollectedData; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; +use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\Dependency\RootExportedNode; use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\InClassNode; @@ -40,6 +41,9 @@ final class FileAnalyserCallback /** @var array */ private array $usedTraitFileDependencies = []; + /** @var array */ + private array $filePackageDependencies = []; + /** @var list */ private array $exportedNodes = []; @@ -69,6 +73,7 @@ public function __construct( private array $ignoreErrorExtensions, private Parser $parser, private DependencyResolver $dependencyResolver, + private PackageDependencyResolver $packageDependencyResolver, private RuleErrorTransformer $ruleErrorTransformer, private array $processedFiles, ) @@ -215,6 +220,9 @@ public function __invoke(Node $node, Scope $scope): void foreach ($dependencies->getFileDependencies($scope->getFile(), $this->analysedFiles) as $dependentFile) { $this->fileDependencies[] = $dependentFile; } + foreach ($dependencies->getPackageDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver) as $package) { + $this->filePackageDependencies[] = $package; + } if ($dependencies->getExportedNode() !== null) { $this->exportedNodes[] = $dependencies->getExportedNode(); } @@ -234,6 +242,9 @@ public function __invoke(Node $node, Scope $scope): void foreach ($usedTraitDependencies->getFileDependencies($scope->getFile(), $this->analysedFiles) as $dependentFile) { $this->usedTraitFileDependencies[] = $dependentFile; } + foreach ($usedTraitDependencies->getPackageDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver) as $package) { + $this->filePackageDependencies[] = $package; + } } /** @@ -274,6 +285,14 @@ public function getFileDependencies(): array return $this->fileDependencies; } + /** + * @return array + */ + public function getPackageDependencies(): array + { + return $this->filePackageDependencies; + } + /** * @return array */ diff --git a/src/Analyser/FileAnalyserResult.php b/src/Analyser/FileAnalyserResult.php index 320b736c6b7..5d9c5f15226 100644 --- a/src/Analyser/FileAnalyserResult.php +++ b/src/Analyser/FileAnalyserResult.php @@ -21,6 +21,7 @@ final class FileAnalyserResult * @param CollectorData $collectedData * @param list $dependencies * @param list $usedTraitDependencies + * @param list $packageDependencies * @param list $exportedNodes * @param LinesToIgnore $linesToIgnore * @param LinesToIgnore $unmatchedLineIgnores @@ -34,6 +35,7 @@ public function __construct( private array $collectedData, private array $dependencies, private array $usedTraitDependencies, + private array $packageDependencies, private array $exportedNodes, private array $linesToIgnore, private array $unmatchedLineIgnores, @@ -98,6 +100,14 @@ public function getUsedTraitDependencies(): array return $this->usedTraitDependencies; } + /** + * @return list + */ + public function getPackageDependencies(): array + { + return $this->packageDependencies; + } + /** * @return list */ diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index 5cfefc6f46a..b3fa2cdc01c 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -24,6 +24,7 @@ final class ResultCache * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies + * @param array> $packageDependencies * @param array> $exportedNodes * @param array $projectExtensionFiles * @param array $currentFileHashes @@ -40,6 +41,7 @@ public function __construct( private array $collectedData, private array $dependencies, private array $usedTraitDependencies, + private array $packageDependencies, private array $exportedNodes, private array $projectExtensionFiles, private array $currentFileHashes, @@ -129,6 +131,14 @@ public function getUsedTraitDependencies(): array return $this->usedTraitDependencies; } + /** + * @return array> + */ + public function getPackageDependencies(): array + { + return $this->packageDependencies; + } + /** * @return array> */ diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 0bc1e756e5b..e3172812c5b 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -10,6 +10,7 @@ use PHPStan\Command\Output; use PHPStan\Dependency\ExportedNode\ExportedTraitNode; use PHPStan\Dependency\ExportedNodeFetcher; +use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\Dependency\RootExportedNode; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\Container; @@ -61,7 +62,7 @@ final class ResultCacheManager { - private const CACHE_VERSION = 'v12-linesToIgnore'; + private const CACHE_VERSION = 'v13-packageDependencies'; /** @var array */ private array $fileHashes = []; @@ -86,6 +87,7 @@ public function __construct( private FileFinder $scanFileFinder, private StubFilesProvider $stubFilesProvider, private FileHelper $fileHelper, + private PackageDependencyResolver $packageDependencyResolver, #[AutowiredParameter(ref: '%resultCachePath%')] private string $cacheFilePath, #[AutowiredParameter] @@ -145,6 +147,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -166,6 +169,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -189,6 +193,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -216,6 +221,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -240,6 +246,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -247,27 +254,58 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } $meta = $this->getMeta($allAnalysedFiles, $projectConfigArray); + $packageDependencies = $data['packageDependencies'] ?? []; + $packageSeededFiles = []; if ($this->isMetaDifferent($data['meta'], $meta)) { + $diffs = $this->getMetaKeyDifferences($data['meta'], $meta); + + // If the metadata differ ONLY in the Composer lock/installed files, the generated container + // and analysis are unchanged except for code coming from packages whose version actually + // changed. Re-analyse just the files depending on a changed package instead of everything; + // the existing incremental loop below then propagates to their dependents on signature change. + // Any other meta difference, or an undetermined change set, falls back to a full re-analysis. + $changedPackages = array_diff($diffs, ['composerLocks', 'composerInstalled']) === [] + ? $this->packageDependencyResolver->getChangedComposerPackages($data['meta'], $meta) + : null; + + if ($changedPackages === null || $changedPackages === []) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache not used because the metadata do not match: ' . implode(', ', $diffs)); + } + return new ResultCache( + filesToAnalyse: $allAnalysedFiles, + fullAnalysis: true, + lastFullAnalysisTime: time(), + meta: $meta, + errors: [], + locallyIgnoredErrors: [], + linesToIgnore: [], + unmatchedLineIgnores: [], + collectedData: [], + dependencies: [], + usedTraitDependencies: [], + packageDependencies: [], + exportedNodes: [], + projectExtensionFiles: [], + currentFileHashes: $currentFileHashes, + ); + } + if ($output->isVeryVerbose()) { - $diffs = $this->getMetaKeyDifferences($data['meta'], $meta); - $output->writeLineFormatted('Result cache not used because the metadata do not match: ' . implode(', ', $diffs)); + $output->writeLineFormatted(sprintf( + 'Composer packages changed (%s); re-analysing only the files depending on them.', + implode(', ', $changedPackages), + )); + } + $changedPackagesLookup = array_fill_keys($changedPackages, true); + foreach ($packageDependencies as $packageDependentFile => $filePackages) { + foreach ($filePackages as $filePackage) { + if (isset($changedPackagesLookup[$filePackage])) { + $packageSeededFiles[] = $packageDependentFile; + break; + } + } } - return new ResultCache( - filesToAnalyse: $allAnalysedFiles, - fullAnalysis: true, - lastFullAnalysisTime: time(), - meta: $meta, - errors: [], - locallyIgnoredErrors: [], - linesToIgnore: [], - unmatchedLineIgnores: [], - collectedData: [], - dependencies: [], - usedTraitDependencies: [], - exportedNodes: [], - projectExtensionFiles: [], - currentFileHashes: $currentFileHashes, - ); } $daysOldForSkip = $this->skipResultCacheIfOlderThanDays; @@ -289,6 +327,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -319,6 +358,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -345,6 +385,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], projectExtensionFiles: [], currentFileHashes: $currentFileHashes, @@ -483,6 +524,13 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } } + foreach ($packageSeededFiles as $packageSeededFile) { + if (!is_file($packageSeededFile)) { + continue; + } + $filesToAnalyse[] = $packageSeededFile; + } + $filesToAnalyse = array_unique($filesToAnalyse); $filesToAnalyseCount = count($filesToAnalyse); @@ -512,6 +560,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: $filteredCollectedData, dependencies: $invertedDependenciesToReturn, usedTraitDependencies: $invertedUsedTraitDependenciesToReturn, + packageDependencies: $packageDependencies, exportedNodes: $filteredExportedNodes, projectExtensionFiles: $data['projectExtensionFiles'], currentFileHashes: $currentFileHashes, @@ -542,6 +591,16 @@ private function isMetaDifferent(array $cachedMeta, array $currentMeta): bool */ private function getMetaKeyDifferences(array $cachedMeta, array $currentMeta): array { + // Normalize projectConfig the same way isMetaDifferent() does: the cached value is a + // Neon-encoded string while the current one is a raw array, so a plain === would always + // report projectConfig as different. + $projectConfig = $currentMeta['projectConfig']; + if ($projectConfig !== null) { + ksort($currentMeta['projectConfig']); + + $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); + } + $diffs = []; foreach ($cachedMeta as $key => $value) { if (!array_key_exists($key, $currentMeta)) { @@ -624,7 +683,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 $packageDependencies, array $exportedNodes, array $projectExtensionFiles) 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.'); @@ -643,6 +702,12 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } return false; } + if ($packageDependencies === null) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because of error in package dependencies.'); + } + return false; + } if (count($internalErrors) > 0) { if ($output->isVeryVerbose()) { @@ -672,7 +737,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, $packageDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -688,7 +753,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->getPackageDependencies(), $analyserResult->getExportedNodes(), $projectExtensionFiles); } else { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because it was not requested.'); @@ -703,6 +768,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); $dependencies = $this->mergeDependencies($resultCache->getDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getDependencies()); $usedTraitDependencies = $this->mergeDependencies($resultCache->getUsedTraitDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getUsedTraitDependencies()); + $packageDependencies = $this->mergePackageDependencies($resultCache->getPackageDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getPackageDependencies()); $exportedNodes = $this->mergeExportedNodes($resultCache, $analyserResult->getExportedNodes()); $linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore()); $unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores()); @@ -729,7 +795,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, $packageDependencies, $exportedNodes, $projectExtensionFiles); } $flatErrors = []; @@ -757,6 +823,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache collectedData: $collectedDataByFile, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, + packageDependencies: $packageDependencies, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), @@ -899,6 +966,44 @@ private function mergeExportedNodes(ResultCache $resultCache, array $freshExport return $newExportedNodes; } + /** + * @param array> $resultCachePackageDependencies + * @param string[] $filesToAnalyse + * @param array>|null $freshPackageDependencies + * @return array>|null + */ + private function mergePackageDependencies(array $resultCachePackageDependencies, array $filesToAnalyse, ?array $freshPackageDependencies): ?array + { + if ($freshPackageDependencies === null) { + return null; + } + + $newPackageDependencies = $resultCachePackageDependencies; + foreach ($filesToAnalyse as $file) { + if (array_key_exists($file, $this->fileReplacements)) { + unset($newPackageDependencies[$file]); + $file = $this->fileReplacements[$file]; + } + if (!array_key_exists($file, $freshPackageDependencies)) { + unset($newPackageDependencies[$file]); + continue; + } + + $newPackageDependencies[$file] = $freshPackageDependencies[$file]; + } + + return $newPackageDependencies; + } + + /** + * Project package names whose installed version/reference changed between two metadata snapshots, + * or null if the change set cannot be reliably determined (then the caller falls back to a full + * re-analysis rather than risk under-invalidation). + * + * @param mixed[] $cachedMeta + * @param mixed[] $currentMeta + * @return list|null + */ /** * @param array $freshLinesToIgnore * @return array @@ -953,6 +1058,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array>> $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies + * @param array> $packageDependencies * @param array> $exportedNodes * @param array $projectExtensionFiles * @param array $currentFileHashes @@ -967,6 +1073,7 @@ private function save( array $collectedData, array $dependencies, array $usedTraitDependencies, + array $packageDependencies, array $exportedNodes, array $projectExtensionFiles, array $currentFileHashes, @@ -1060,6 +1167,7 @@ private function save( 'unmatchedLineIgnores' => " . var_export($unmatchedLineIgnores, true) . ", 'collectedDataCallback' => static function (): array { return " . var_export($collectedData, true) . "; }, 'dependencies' => " . var_export($invertedDependencies, true) . ", + 'packageDependencies' => " . var_export($packageDependencies, true) . ", 'exportedNodesCallback' => static function (): array { return " . var_export($exportedNodes, true) . '; }, ]; ', diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index e78212f492c..2cc886eb0f9 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -119,6 +119,7 @@ public function analyse( collectedData: $intermediateAnalyserResult->getCollectedData(), dependencies: $intermediateAnalyserResult->getDependencies(), usedTraitDependencies: $intermediateAnalyserResult->getUsedTraitDependencies(), + packageDependencies: $intermediateAnalyserResult->getPackageDependencies(), exportedNodes: $intermediateAnalyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $intermediateAnalyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $intermediateAnalyserResult->getPeakMemoryUsageBytes(), @@ -241,6 +242,7 @@ private function runAnalyser( collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, peakMemoryUsageBytes: memory_get_peak_usage(true), @@ -328,6 +330,10 @@ private function switchTmpFileInAnalyserResult( if ($analyserResult->getUsedTraitDependencies() !== null) { $usedTraitDependencies = $this->switchTmpFileInDependencies($analyserResult->getUsedTraitDependencies(), $insteadOfFile, $tmpFile); } + $packageDependencies = null; + if ($analyserResult->getPackageDependencies() !== null) { + $packageDependencies = $this->switchTmpFileInDependencies($analyserResult->getPackageDependencies(), $insteadOfFile, $tmpFile); + } $exportedNodes = []; foreach ($analyserResult->getExportedNodes() as $file => $fileExportedNodes) { @@ -349,6 +355,7 @@ private function switchTmpFileInAnalyserResult( collectedData: $newCollectedData, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, + packageDependencies: $packageDependencies, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php index fba6e420608..dac67670a14 100644 --- a/src/Command/AnalyserRunner.php +++ b/src/Command/AnalyserRunner.php @@ -66,6 +66,7 @@ public function runAnalyser( collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, peakMemoryUsageBytes: memory_get_peak_usage(true), diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index 9a46b1501b2..dbc0494cdc9 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -345,6 +345,7 @@ private function runAnalyser(LoopInterface $loop, array $files, array $allAnalys collectedData: [], dependencies: [], usedTraitDependencies: [], + packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, peakMemoryUsageBytes: memory_get_peak_usage(true), diff --git a/src/Dependency/NodeDependencies.php b/src/Dependency/NodeDependencies.php index 3ccb203762e..6c944853d08 100644 --- a/src/Dependency/NodeDependencies.php +++ b/src/Dependency/NodeDependencies.php @@ -62,6 +62,46 @@ public function getFileDependencies(string $currentFile, array $analysedFiles): return array_values($dependencies); } + /** + * Project packages this file's reflections depend on. Mirrors getFileDependencies() but keeps the + * vendor files it drops, resolved to the analysed project's Composer package, so a composer.lock + * change can re-analyse only the files that depend on a package whose version changed. + * + * @param array $analysedFiles + * @return list + */ + public function getPackageDependencies(string $currentFile, array $analysedFiles, PackageDependencyResolver $packageDependencyResolver): array + { + $packages = []; + + foreach ($this->reflections as $dependencyReflection) { + $dependencyFile = $dependencyReflection->getFileName(); + if ($dependencyFile === null) { + continue; + } + + $dependencyFile = $this->fileHelper->normalizePath($dependencyFile); + + if ($currentFile === $dependencyFile) { + continue; + } + + if (isset($analysedFiles[$dependencyFile])) { + // Analysed file: already tracked as a file-to-file dependency. + continue; + } + + $package = $packageDependencyResolver->resolvePackage($dependencyFile); + if ($package === null) { + continue; + } + + $packages[$package] = $package; + } + + return array_values($packages); + } + public function getExportedNode(): ?RootExportedNode { return $this->exportedNode; diff --git a/src/Dependency/PackageDependencyResolver.php b/src/Dependency/PackageDependencyResolver.php new file mode 100644 index 00000000000..b707e7a5f1a --- /dev/null +++ b/src/Dependency/PackageDependencyResolver.php @@ -0,0 +1,162 @@ +|null normalized install path => package name, longest path first */ + private ?array $installPathToPackage = null; + + /** @param string[] $composerAutoloaderProjectPaths */ + public function __construct( + private array $composerAutoloaderProjectPaths, + private FileHelper $fileHelper, + ) + { + } + + public function resolvePackage(string $file): ?string + { + // Normalize with a forward slash regardless of platform: normalizePath() defaults to + // DIRECTORY_SEPARATOR, so on Windows the paths would use '\' while the prefix check below + // appends '/', and nothing would ever match. + $normalizedFile = $this->fileHelper->normalizePath($file, '/'); + foreach ($this->getInstallPathToPackage() as $installPath => $package) { + if (str_starts_with($normalizedFile, $installPath . '/')) { + return $package; + } + } + + return null; + } + + /** + * Names of packages whose recorded version/reference differs between two result-cache metas, or + * null when either meta's composerInstalled cannot be parsed (the caller falls back to a full + * re-analysis rather than risk under-invalidation). + * + * @param mixed[] $cachedMeta + * @param mixed[] $currentMeta + * @return list|null + */ + public function getChangedComposerPackages(array $cachedMeta, array $currentMeta): ?array + { + $cached = $this->extractComposerPackageVersions($cachedMeta['composerInstalled'] ?? null); + $current = $this->extractComposerPackageVersions($currentMeta['composerInstalled'] ?? null); + if ($cached === null || $current === null) { + return null; + } + + $changed = []; + foreach ($current as $package => $version) { + if (array_key_exists($package, $cached) && $cached[$package] === $version) { + continue; + } + + $changed[$package] = $package; + } + foreach (array_keys($cached) as $package) { + if (array_key_exists($package, $current)) { + continue; + } + + $changed[$package] = $package; + } + + return array_values($changed); + } + + /** + * @return array|null + */ + public function extractComposerPackageVersions(mixed $composerInstalled): ?array + { + if (!is_array($composerInstalled)) { + return null; + } + + $versions = []; + foreach ($composerInstalled as $installed) { + if (!is_array($installed) || !isset($installed['versions']) || !is_array($installed['versions'])) { + return null; + } + foreach ($installed['versions'] as $package => $info) { + if (!is_string($package) || !is_array($info)) { + return null; + } + $reference = $info['reference'] ?? $info['version'] ?? $info['pretty_version'] ?? null; + $versions[$package] = is_string($reference) ? $reference : ''; + } + } + + return $versions; + } + + /** @return array */ + private function getInstallPathToPackage(): array + { + if ($this->installPathToPackage !== null) { + return $this->installPathToPackage; + } + + $map = []; + foreach ($this->composerAutoloaderProjectPaths as $autoloadPath) { + $composer = ComposerHelper::getComposerConfig($autoloadPath); + if ($composer === null) { + continue; + } + + $installedPhp = ComposerHelper::getVendorDirFromComposerConfig($autoloadPath, $composer) . '/composer/installed.php'; + if (!is_file($installedPhp)) { + continue; + } + + $installed = require $installedPhp; + if (!is_array($installed) || !isset($installed['versions']) || !is_array($installed['versions'])) { + continue; + } + + $root = $installed['root'] ?? null; + $rootName = is_array($root) && isset($root['name']) && is_string($root['name']) ? $root['name'] : null; + + foreach ($installed['versions'] as $package => $info) { + if (!is_string($package) || $package === $rootName) { + continue; + } + if (!is_array($info) || !isset($info['install_path']) || !is_string($info['install_path'])) { + continue; + } + + $map[$this->fileHelper->normalizePath($info['install_path'], '/')] = $package; + } + } + + // Longest install path first, so a package nested under another package's directory matches first. + uksort($map, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + return $this->installPathToPackage = $map; + } + +} diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index c5be7a4e546..4d229f68e76 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -94,6 +94,7 @@ public function analyse( $collectedData = []; $dependencies = []; $usedTraitDependencies = []; + $packageDependencies = []; $reachedInternalErrorsCountLimit = false; $exportedNodes = []; /** @var list $allProcessedFiles */ @@ -103,7 +104,7 @@ public function analyse( $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, &$allProcessedFiles): void { + $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles): void { if (count($jobs) > 0 && $internalErrorsCount === 0) { $internalErrors[] = new InternalError( 'Some parallel worker jobs have not finished.', @@ -126,6 +127,7 @@ public function analyse( collectedData: $collectedData, dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, + packageDependencies: $internalErrorsCount === 0 ? $packageDependencies : null, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, peakMemoryUsageBytes: array_sum($peakMemoryUsages), // not 100% correct as the peak usages of workers might not have met @@ -210,7 +212,7 @@ public function analyse( $insteadOfFile, $input, ); - $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, &$allProcessedFiles): void { + $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void { $fileErrors = []; foreach ($json['errors'] as $jsonError) { $fileErrors[] = Error::decode($jsonError); @@ -268,6 +270,14 @@ public function analyse( $usedTraitDependencies[$file] = $fileUsedTraitDependencies; } + /** + * @var string $file + * @var array $filePackageDependencies + */ + foreach ($json['packageDependencies'] as $file => $filePackageDependencies) { + $packageDependencies[$file] = $filePackageDependencies; + } + foreach ($json['linesToIgnore'] as $file => $fileLinesToIgnore) { if (count($fileLinesToIgnore) === 0) { continue; diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index 6cb597dc2d8..c570454e7f9 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -128,6 +128,7 @@ private function runWorker( 'collectedData' => [], 'memoryUsage' => memory_get_peak_usage(true), 'dependencies' => [], + 'packageDependencies' => [], 'exportedNodes' => [], 'files' => [], 'internalErrorsCount' => 1, @@ -157,6 +158,7 @@ private function runWorker( $collectedData = []; $dependencies = []; $usedTraitDependencies = []; + $packageDependencies = []; $exportedNodes = []; $processedFiles = []; foreach ($files as $file) { @@ -172,6 +174,7 @@ private function runWorker( $unmatchedLineIgnores[$file] = $fileAnalyserResult->getUnmatchedLineIgnores(); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $exportedNodes[$file] = $fileAnalyserResult->getExportedNodes(); $processedFiles = array_merge($processedFiles, $fileAnalyserResult->getProcessedFiles()); foreach ($fileErrors as $fileError) { @@ -213,6 +216,7 @@ private function runWorker( 'memoryUsage' => memory_get_peak_usage(true), 'dependencies' => $dependencies, 'usedTraitDependencies' => $usedTraitDependencies, + 'packageDependencies' => $packageDependencies, 'exportedNodes' => $exportedNodes, 'files' => $files, 'processedFiles' => $processedFiles, diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index d8d0ba20e1c..7f137660159 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -18,6 +18,7 @@ use PHPStan\Collectors\Collector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; +use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterOutTypeExtensionProvider; @@ -136,6 +137,7 @@ private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser $nodeScopeResolver, $this->getParser(), self::getContainer()->getByType(DependencyResolver::class), + self::getContainer()->getByType(PackageDependencyResolver::class), new IgnoreErrorExtensionProvider(self::getContainer()), self::getContainer()->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 39da6a5be9e..6f781f28a25 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -12,6 +12,7 @@ use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; use PHPStan\Dependency\ExportedNodeResolver; +use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\DependencyInjection\Nette\NetteContainer; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; @@ -846,6 +847,7 @@ private function createAnalyser(): Analyser new IgnoreLexer(), ), new DependencyResolver($fileHelper, $reflectionProvider, new ExportedNodeResolver($reflectionProvider, $fileTypeMapper, new ExprPrinter(new Printer())), $fileTypeMapper), + new PackageDependencyResolver([], $fileHelper), new IgnoreErrorExtensionProvider(new NetteContainer(new Container([]))), $container->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), diff --git a/tests/PHPStan/Dependency/PackageDependencyResolverTest.php b/tests/PHPStan/Dependency/PackageDependencyResolverTest.php new file mode 100644 index 00000000000..5928940c684 --- /dev/null +++ b/tests/PHPStan/Dependency/PackageDependencyResolverTest.php @@ -0,0 +1,91 @@ +getByType(FileHelper::class); + $resolver = new PackageDependencyResolver([$fixtureRoot], $fileHelper); + + // Files under a project package install path resolve to that package. + $this->assertSame('acme/widget', $resolver->resolvePackage($fixtureRoot . '/vendor/acme/widget/src/Widget.php')); + $this->assertSame('acme/gadget', $resolver->resolvePackage($fixtureRoot . '/vendor/acme/gadget/Gadget.php')); + + // The root package is skipped, so project sources belong to no package. + $this->assertNull($resolver->resolvePackage($fixtureRoot . '/src/App.php')); + + // A path outside the project entirely. + $this->assertNull($resolver->resolvePackage('/outside/the/project/File.php')); + } + + public function testExtractComposerPackageVersions(): void + { + $resolver = new PackageDependencyResolver([], self::getContainer()->getByType(FileHelper::class)); + + // reference is preferred, then version, then pretty_version, else the empty string. + $this->assertSame( + ['acme/a' => 'ref-a', 'acme/b' => '2.0.0', 'acme/c' => 'dev-main', 'acme/d' => ''], + $resolver->extractComposerPackageVersions([ + [ + 'versions' => [ + 'acme/a' => ['reference' => 'ref-a', 'version' => '1.0.0'], + 'acme/b' => ['version' => '2.0.0'], + 'acme/c' => ['pretty_version' => 'dev-main'], + 'acme/d' => [], + ], + ], + ]), + ); + + // Multiple installed.php entries (one per autoload path) merge. + $this->assertSame( + ['acme/a' => 'ref-a', 'acme/b' => 'ref-b'], + $resolver->extractComposerPackageVersions([ + ['versions' => ['acme/a' => ['reference' => 'ref-a']]], + ['versions' => ['acme/b' => ['reference' => 'ref-b']]], + ]), + ); + + // Unparseable shapes return null, so the caller falls back to a full re-analysis. + $this->assertNull($resolver->extractComposerPackageVersions(null)); + $this->assertNull($resolver->extractComposerPackageVersions('not-an-array')); + $this->assertNull($resolver->extractComposerPackageVersions([['no-versions-key' => []]])); + } + + public function testGetChangedComposerPackages(): void + { + $resolver = new PackageDependencyResolver([], self::getContainer()->getByType(FileHelper::class)); + + $cached = ['composerInstalled' => [['versions' => [ + 'acme/stable' => ['reference' => 'r1'], + 'acme/bumped' => ['reference' => 'old'], + 'acme/removed' => ['reference' => 'r2'], + ]]]]; + $current = ['composerInstalled' => [['versions' => [ + 'acme/stable' => ['reference' => 'r1'], + 'acme/bumped' => ['reference' => 'new'], + 'acme/added' => ['reference' => 'r3'], + ]]]]; + + // A bumped reference, a newly added package and a removed package all count as changed; + // the unchanged package does not. + $this->assertSame( + ['acme/bumped', 'acme/added', 'acme/removed'], + $resolver->getChangedComposerPackages($cached, $current), + ); + + // Identical metas mean nothing changed. + $this->assertSame([], $resolver->getChangedComposerPackages($cached, $cached)); + + // An unparseable meta returns null, so the caller falls back to a full re-analysis. + $this->assertNull($resolver->getChangedComposerPackages(['composerInstalled' => 'broken'], $current)); + } + +} diff --git a/tests/PHPStan/Dependency/data/package-resolver/composer.json b/tests/PHPStan/Dependency/data/package-resolver/composer.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/PHPStan/Dependency/data/package-resolver/composer.json @@ -0,0 +1 @@ +{} diff --git a/tests/PHPStan/Dependency/data/package-resolver/vendor/composer/installed.php b/tests/PHPStan/Dependency/data/package-resolver/vendor/composer/installed.php new file mode 100644 index 00000000000..4f4adaa18cf --- /dev/null +++ b/tests/PHPStan/Dependency/data/package-resolver/vendor/composer/installed.php @@ -0,0 +1,24 @@ + [ + 'name' => 'acme/project', + 'install_path' => __DIR__ . '/../../', + ], + 'versions' => [ + 'acme/project' => [ + 'pretty_version' => '1.0.0+no-version-set', + 'install_path' => __DIR__ . '/../../', + ], + 'acme/widget' => [ + 'pretty_version' => '1.0.0', + 'reference' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'install_path' => __DIR__ . '/../acme/widget', + ], + 'acme/gadget' => [ + 'pretty_version' => '2.0.0', + 'reference' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'install_path' => __DIR__ . '/../acme/gadget', + ], + ], +];