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