Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions e2e/result-cache-extension-installer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor
/composer.lock
6 changes: 6 additions & 0 deletions e2e/result-cache-extension-installer/composer.json
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions e2e/result-cache-extension-installer/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
level: 0
tmpDir: tmp
paths:
- src
6 changes: 6 additions & 0 deletions e2e/result-cache-extension-installer/rule-pkg/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "test/installer-rule",
"version": "1.0.0",
"autoload": { "psr-4": { "Test\\InstallerRule\\": "src" } },
"extra": { "phpstan": { "includes": ["extension.neon"] } }
}
5 changes: 5 additions & 0 deletions e2e/result-cache-extension-installer/rule-pkg/extension.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
-
class: Test\InstallerRule\ClassNameRule
tags:
- phpstan.rules.rule
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);

namespace Test\InstallerRule;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<Node\Stmt\Class_>
*/
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(),
];
}

}
7 changes: 7 additions & 0 deletions e2e/result-cache-extension-installer/src/Subject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php declare(strict_types = 1);

namespace App;

class Subject
{
}
2 changes: 2 additions & 0 deletions e2e/result-cache-extension-installer/tmp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.*
21 changes: 21 additions & 0 deletions e2e/result-cache-extension-installer/updatePackage.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
--- a/rule-pkg/composer.json 2026-07-22 21:34:57
+++ b/rule-pkg/composer.json 2026-07-22 21:34:57
@@ -1,6 +1,6 @@
{
"name": "test/installer-rule",
- "version": "1.0.0",
+ "version": "2.0.0",
"autoload": { "psr-4": { "Test\\InstallerRule\\": "src" } },
"extra": { "phpstan": { "includes": ["extension.neon"] } }
}
--- a/rule-pkg/src/ClassNameRule.php 2026-07-22 21:34:57
+++ b/rule-pkg/src/ClassNameRule.php 2026-07-22 21:34:57
@@ -21,7 +21,7 @@
public function processNode(Node $node, Scope $scope): array
{
return [
- RuleErrorBuilder::message('installer rule version 1')
+ RuleErrorBuilder::message('installer rule version 2')
->identifier('test.installerRule')
->build(),
];
2 changes: 2 additions & 0 deletions e2e/result-cache-extension-update/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor
/composer.lock
5 changes: 5 additions & 0 deletions e2e/result-cache-extension-update/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"repositories": [ { "type": "path", "url": "./rule-ext", "options": { "symlink": false } } ],
"require": { "test/rule-ext": "*" },
"minimum-stability": "dev", "prefer-stable": true
}
8 changes: 8 additions & 0 deletions e2e/result-cache-extension-update/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
includes:
- vendor/test/rule-ext/extension.neon

parameters:
level: 0
tmpDir: tmp
paths:
- src
1 change: 1 addition & 0 deletions e2e/result-cache-extension-update/rule-ext/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "test/rule-ext", "version": "1.0.0", "autoload": { "psr-4": { "Test\\RuleExt\\": "src" } } }
5 changes: 5 additions & 0 deletions e2e/result-cache-extension-update/rule-ext/extension.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
-
class: Test\RuleExt\ClassNameRule
tags:
- phpstan.rules.rule
30 changes: 30 additions & 0 deletions e2e/result-cache-extension-update/rule-ext/src/ClassNameRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);

namespace Test\RuleExt;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<Node\Stmt\Class_>
*/
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(),
];
}

}
7 changes: 7 additions & 0 deletions e2e/result-cache-extension-update/src/Subject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php declare(strict_types = 1);

namespace App;

class Subject
{
}
2 changes: 2 additions & 0 deletions e2e/result-cache-extension-update/tmp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.*
16 changes: 16 additions & 0 deletions e2e/result-cache-extension-update/updatePackage.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
--- a/rule-ext/composer.json 2026-07-22 18:52:01
+++ b/rule-ext/composer.json 2026-07-22 18:52:01
@@ -1 +1 @@
-{ "name": "test/rule-ext", "version": "1.0.0", "autoload": { "psr-4": { "Test\\RuleExt\\": "src" } } }
+{ "name": "test/rule-ext", "version": "2.0.0", "autoload": { "psr-4": { "Test\\RuleExt\\": "src" } } }
--- a/rule-ext/src/ClassNameRule.php 2026-07-22 18:52:01
+++ b/rule-ext/src/ClassNameRule.php 2026-07-22 18:52:01
@@ -21,7 +21,7 @@
public function processNode(Node $node, Scope $scope): array
{
return [
- RuleErrorBuilder::message('extension rule version 1')
+ RuleErrorBuilder::message('extension rule version 2')
->identifier('test.ruleExt')
->build(),
];
79 changes: 78 additions & 1 deletion src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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])) {
Expand Down Expand Up @@ -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<string, true> $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<string, mixed> $dependencies
Expand Down
Loading