From 00d5e6d0774457dfb7068362d3b2b0fdef3a631b Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 22 Jul 2026 18:28:15 +0200 Subject: [PATCH] Invalidate the whole result cache when a changed package registers a container class The package-granular invalidation only re-seeds files that depend on a changed Composer package through code references. A package that registers a rule or extension in the PHPStan container (larastan and similar) is not referenced by the analysed code, yet its code can change the analysis of every file, so the granular re-seed left the cache serving stale results after such a package was updated. Before the package-granular invalidation any composerInstalled change forced a full re-analysis, which happened to cover this. Detect it: when a changed package owns a class registered in the container (ProjectConfigHelper::getServiceClassNames resolved to a package via PackageDependencyResolver::resolvePackage), re-analyse everything instead of only the file-granular dependents. Packages that contribute nothing to the container keep the granular path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 27 +++++++ .../.gitignore | 2 + .../composer.json | 6 ++ .../phpstan.neon | 5 ++ .../rule-pkg/composer.json | 6 ++ .../rule-pkg/extension.neon | 5 ++ .../rule-pkg/src/ClassNameRule.php | 30 +++++++ .../src/Subject.php | 7 ++ .../tmp/.gitignore | 2 + .../updatePackage.patch | 21 +++++ e2e/result-cache-extension-update/.gitignore | 2 + .../composer.json | 5 ++ .../phpstan.neon | 8 ++ .../rule-ext/composer.json | 1 + .../rule-ext/extension.neon | 5 ++ .../rule-ext/src/ClassNameRule.php | 30 +++++++ .../src/Subject.php | 7 ++ .../tmp/.gitignore | 2 + .../updatePackage.patch | 16 ++++ .../ResultCache/ResultCacheManager.php | 79 ++++++++++++++++++- 20 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 e2e/result-cache-extension-installer/.gitignore create mode 100644 e2e/result-cache-extension-installer/composer.json create mode 100644 e2e/result-cache-extension-installer/phpstan.neon create mode 100644 e2e/result-cache-extension-installer/rule-pkg/composer.json create mode 100644 e2e/result-cache-extension-installer/rule-pkg/extension.neon create mode 100644 e2e/result-cache-extension-installer/rule-pkg/src/ClassNameRule.php create mode 100644 e2e/result-cache-extension-installer/src/Subject.php create mode 100644 e2e/result-cache-extension-installer/tmp/.gitignore create mode 100644 e2e/result-cache-extension-installer/updatePackage.patch create mode 100644 e2e/result-cache-extension-update/.gitignore create mode 100644 e2e/result-cache-extension-update/composer.json create mode 100644 e2e/result-cache-extension-update/phpstan.neon create mode 100644 e2e/result-cache-extension-update/rule-ext/composer.json create mode 100644 e2e/result-cache-extension-update/rule-ext/extension.neon create mode 100644 e2e/result-cache-extension-update/rule-ext/src/ClassNameRule.php create mode 100644 e2e/result-cache-extension-update/src/Subject.php create mode 100644 e2e/result-cache-extension-update/tmp/.gitignore create mode 100644 e2e/result-cache-extension-update/updatePackage.patch diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3a905bafc74..9f701b80ade 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -413,6 +413,33 @@ jobs: echo "$OUTPUT" ../bashunit -a contains 'Composer packages changed (psr/log); re-analysing only the files depending on them.' "$OUTPUT" ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + - script: | + cd e2e/result-cache-extension-update + composer install + # cold run populates the cache; the shipped rule reports an error, so ignore the exit code + ../../bin/phpstan analyse || true + # Update the package that registers the rule: bump its version and change the rule, then let + # Composer reinstall it. Because the package registers a class in the container, the whole + # cache must be invalidated, not just the files depending on the package. + patch -p1 < updatePackage.patch + composer update test/rule-ext --no-cache + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'and register a class in the container; re-analysing everything.' "$OUTPUT" + ../bashunit -a contains 'Subject.php:5:extension rule version 2' "$OUTPUT" + - script: | + cd e2e/result-cache-extension-installer + composer install + # cold run populates the cache; the shipped rule reports an error, so ignore the exit code + ../../bin/phpstan analyse || true + # Same as result-cache-extension-update, but the rule package registers its extension through + # phpstan/extension-installer (no includes: in the project config), which is the common case. + patch -p1 < updatePackage.patch + composer update test/installer-rule --no-cache + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'and register a class in the container; re-analysing everything.' "$OUTPUT" + ../bashunit -a contains 'Subject.php:5:installer rule version 2' "$OUTPUT" - script: | cd e2e/bug-12606 export CONFIGTEST=test diff --git a/e2e/result-cache-extension-installer/.gitignore b/e2e/result-cache-extension-installer/.gitignore new file mode 100644 index 00000000000..de4a392c331 --- /dev/null +++ b/e2e/result-cache-extension-installer/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/e2e/result-cache-extension-installer/composer.json b/e2e/result-cache-extension-installer/composer.json new file mode 100644 index 00000000000..5ca6077fde7 --- /dev/null +++ b/e2e/result-cache-extension-installer/composer.json @@ -0,0 +1,6 @@ +{ + "repositories": [ { "type": "path", "url": "./rule-pkg", "options": { "symlink": false } } ], + "require": { "test/installer-rule": "*", "phpstan/extension-installer": "^1.4" }, + "config": { "allow-plugins": { "phpstan/extension-installer": true } }, + "minimum-stability": "dev", "prefer-stable": true +} diff --git a/e2e/result-cache-extension-installer/phpstan.neon b/e2e/result-cache-extension-installer/phpstan.neon new file mode 100644 index 00000000000..f6853ea4d8e --- /dev/null +++ b/e2e/result-cache-extension-installer/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 0 + tmpDir: tmp + paths: + - src diff --git a/e2e/result-cache-extension-installer/rule-pkg/composer.json b/e2e/result-cache-extension-installer/rule-pkg/composer.json new file mode 100644 index 00000000000..f1ec790393d --- /dev/null +++ b/e2e/result-cache-extension-installer/rule-pkg/composer.json @@ -0,0 +1,6 @@ +{ + "name": "test/installer-rule", + "version": "1.0.0", + "autoload": { "psr-4": { "Test\\InstallerRule\\": "src" } }, + "extra": { "phpstan": { "includes": ["extension.neon"] } } +} diff --git a/e2e/result-cache-extension-installer/rule-pkg/extension.neon b/e2e/result-cache-extension-installer/rule-pkg/extension.neon new file mode 100644 index 00000000000..4d127ac8c07 --- /dev/null +++ b/e2e/result-cache-extension-installer/rule-pkg/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Test\InstallerRule\ClassNameRule + tags: + - phpstan.rules.rule diff --git a/e2e/result-cache-extension-installer/rule-pkg/src/ClassNameRule.php b/e2e/result-cache-extension-installer/rule-pkg/src/ClassNameRule.php new file mode 100644 index 00000000000..967bede1c7a --- /dev/null +++ b/e2e/result-cache-extension-installer/rule-pkg/src/ClassNameRule.php @@ -0,0 +1,30 @@ + + */ +final class ClassNameRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [ + RuleErrorBuilder::message('installer rule version 1') + ->identifier('test.installerRule') + ->build(), + ]; + } + +} diff --git a/e2e/result-cache-extension-installer/src/Subject.php b/e2e/result-cache-extension-installer/src/Subject.php new file mode 100644 index 00000000000..d80625c6e6a --- /dev/null +++ b/e2e/result-cache-extension-installer/src/Subject.php @@ -0,0 +1,7 @@ +identifier('test.installerRule') + ->build(), + ]; diff --git a/e2e/result-cache-extension-update/.gitignore b/e2e/result-cache-extension-update/.gitignore new file mode 100644 index 00000000000..de4a392c331 --- /dev/null +++ b/e2e/result-cache-extension-update/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/e2e/result-cache-extension-update/composer.json b/e2e/result-cache-extension-update/composer.json new file mode 100644 index 00000000000..2deb445421a --- /dev/null +++ b/e2e/result-cache-extension-update/composer.json @@ -0,0 +1,5 @@ +{ + "repositories": [ { "type": "path", "url": "./rule-ext", "options": { "symlink": false } } ], + "require": { "test/rule-ext": "*" }, + "minimum-stability": "dev", "prefer-stable": true +} diff --git a/e2e/result-cache-extension-update/phpstan.neon b/e2e/result-cache-extension-update/phpstan.neon new file mode 100644 index 00000000000..6db2b392a1f --- /dev/null +++ b/e2e/result-cache-extension-update/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - vendor/test/rule-ext/extension.neon + +parameters: + level: 0 + tmpDir: tmp + paths: + - src diff --git a/e2e/result-cache-extension-update/rule-ext/composer.json b/e2e/result-cache-extension-update/rule-ext/composer.json new file mode 100644 index 00000000000..3b628c27c56 --- /dev/null +++ b/e2e/result-cache-extension-update/rule-ext/composer.json @@ -0,0 +1 @@ +{ "name": "test/rule-ext", "version": "1.0.0", "autoload": { "psr-4": { "Test\\RuleExt\\": "src" } } } diff --git a/e2e/result-cache-extension-update/rule-ext/extension.neon b/e2e/result-cache-extension-update/rule-ext/extension.neon new file mode 100644 index 00000000000..ac34ab33cb1 --- /dev/null +++ b/e2e/result-cache-extension-update/rule-ext/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Test\RuleExt\ClassNameRule + tags: + - phpstan.rules.rule diff --git a/e2e/result-cache-extension-update/rule-ext/src/ClassNameRule.php b/e2e/result-cache-extension-update/rule-ext/src/ClassNameRule.php new file mode 100644 index 00000000000..695ad9ad9bc --- /dev/null +++ b/e2e/result-cache-extension-update/rule-ext/src/ClassNameRule.php @@ -0,0 +1,30 @@ + + */ +final class ClassNameRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [ + RuleErrorBuilder::message('extension rule version 1') + ->identifier('test.ruleExt') + ->build(), + ]; + } + +} diff --git a/e2e/result-cache-extension-update/src/Subject.php b/e2e/result-cache-extension-update/src/Subject.php new file mode 100644 index 00000000000..d80625c6e6a --- /dev/null +++ b/e2e/result-cache-extension-update/src/Subject.php @@ -0,0 +1,7 @@ +identifier('test.ruleExt') + ->build(), + ]; diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index b603780145a..8f886271c33 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -16,6 +16,7 @@ use PHPStan\DependencyInjection\Container; use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\ProjectConfigHelper; +use PHPStan\ExtensionInstaller\GeneratedConfig; use PHPStan\File\CouldNotReadFileException; use PHPStan\File\CouldNotWriteFileException; use PHPStan\File\FileFinder; @@ -35,6 +36,7 @@ use function array_merge; use function array_unique; use function array_values; +use function class_exists; use function count; use function error_get_last; use function explode; @@ -307,13 +309,43 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $output->writeLineFormatted('Composer metadata changed but no package versions changed; keeping the result cache.'); } } else { + $changedPackagesLookup = array_fill_keys($changedPackages, true); + if ($this->changedPackagesProvideContainerClass($projectConfigArray, $changedPackagesLookup)) { + // One of the changed packages registers a class in the PHPStan container (a rule, + // extension, and so on). Such code can affect the analysis of every file, not just the + // files that reference it, so the file-granular re-seed below is not enough - re-analyse + // everything. + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf( + 'Composer packages changed (%s) and register a class in the container; re-analysing everything.', + implode(', ', $changedPackages), + )); + } + 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()) { $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])) { @@ -1262,6 +1294,51 @@ private function streamArrayVarExportToHandle($handle, string $file, array $valu $this->writeToHandle($handle, $file, "\n)"); } + /** + * Whether any of the changed Composer packages registers a class in the PHPStan container (a rule, + * extension, and so on). Such code can affect the analysis of every file, so the file-granular + * package re-seed is not enough and the whole cache must be invalidated. + * + * @param mixed[]|null $projectConfig + * @param array $changedPackagesLookup + */ + private function changedPackagesProvideContainerClass(?array $projectConfig, array $changedPackagesLookup): bool + { + // Extensions registered directly in the project config (services:/rules:) or via an included + // extension neon file: resolve each service class to the package that owns its file. + if ($projectConfig !== null) { + foreach (ProjectConfigHelper::getServiceClassNames($projectConfig) as $class) { + try { + // does not use static reflection to reduce file-parsing, like getProjectExtensionFiles() + $fileName = (new ReflectionClass($class))->getFileName(); /** @phpstan-ignore argument.type */ + } catch (ReflectionException) { + continue; + } + + if ($fileName === false || str_starts_with($fileName, 'phar://')) { + continue; + } + + $package = $this->packageDependencyResolver->resolvePackage($fileName); + if ($package !== null && array_key_exists($package, $changedPackagesLookup)) { + return true; + } + } + } + + // Extensions registered through phpstan/extension-installer are not part of the project config; + // its generated list is keyed by the extension's Composer package name. + if (class_exists('PHPStan\ExtensionInstaller\GeneratedConfig')) { + foreach (array_keys(GeneratedConfig::EXTENSIONS) as $package) { + if (array_key_exists($package, $changedPackagesLookup)) { + return true; + } + } + } + + return false; + } + /** * @param mixed[]|null $projectConfig * @param array $dependencies