From bdca7e2cfea928ebf61b3892ae8dddd1241c768e Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 22 Jul 2026 14:26:29 +0200 Subject: [PATCH 1/3] Keep the result cache when composer.lock changes but no package version did When the result-cache metadata differed only in the Composer lock/installed files, PackageDependencyResolver::getChangedComposerPackages() was consulted to re-seed just the files depending on a changed package. An empty result (no package version or reference changed) was treated the same as null (installed.php unparseable), so a composer.lock whose bytes changed without any package change discarded the entire cache and re-analysed every file. This is common in CI for projects that do not commit composer.lock: every job regenerates the lock, and any run-to-run difference that is not a package-version change forced a full re-analysis on top of a cache that was restored correctly. Distinguish the two: null still falls back to a full re-analysis, while an empty change set keeps the restored cache and continues with the normal incremental analysis. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 13 ++++++ e2e/result-cache-composer-lock/.gitignore | 1 + e2e/result-cache-composer-lock/composer.json | 6 +++ e2e/result-cache-composer-lock/composer.lock | 18 +++++++++ e2e/result-cache-composer-lock/phpstan.neon | 5 +++ e2e/result-cache-composer-lock/src/Foo.php | 13 ++++++ e2e/result-cache-composer-lock/tmp/.gitignore | 2 + .../ResultCache/ResultCacheManager.php | 40 ++++++++++++------- 8 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 e2e/result-cache-composer-lock/.gitignore create mode 100644 e2e/result-cache-composer-lock/composer.json create mode 100644 e2e/result-cache-composer-lock/composer.lock create mode 100644 e2e/result-cache-composer-lock/phpstan.neon create mode 100644 e2e/result-cache-composer-lock/src/Foo.php create mode 100644 e2e/result-cache-composer-lock/tmp/.gitignore diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6a8715514db..4b92052d15d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -379,6 +379,19 @@ jobs: OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" ../bashunit -a contains 'ClassUsingDefine.php:13:Method ResultCacheE2EDefine\ClassUsingDefine::getMode() should return int<1, 3> but returns 5. [identifier=return.type]' "$OUTPUT" + - script: | + cd e2e/result-cache-composer-lock + composer install + ../../bin/phpstan analyse + # Change composer.lock without changing any installed package version/reference + # (vendor/composer/installed.php is left untouched), like a regenerated lock in CI + # when composer.lock is not committed. The result cache must be kept, not discarded. + jq '.["content-hash"]="0000000000000000000000000000000000000000"' composer.lock > composer.lock.new + mv composer.lock.new composer.lock + OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv") + echo "$OUTPUT" + ../bashunit -a contains 'Composer metadata changed but no package versions changed; keeping the result cache.' "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" - script: | cd e2e/bug-12606 export CONFIGTEST=test diff --git a/e2e/result-cache-composer-lock/.gitignore b/e2e/result-cache-composer-lock/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/result-cache-composer-lock/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/result-cache-composer-lock/composer.json b/e2e/result-cache-composer-lock/composer.json new file mode 100644 index 00000000000..1dc546dd6e4 --- /dev/null +++ b/e2e/result-cache-composer-lock/composer.json @@ -0,0 +1,6 @@ +{ + "name": "phpstan/result-cache-composer-lock-e2e", + "autoload": { + "classmap": ["src"] + } +} diff --git a/e2e/result-cache-composer-lock/composer.lock b/e2e/result-cache-composer-lock/composer.lock new file mode 100644 index 00000000000..e512483dbfe --- /dev/null +++ b/e2e/result-cache-composer-lock/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "432b93b35c547c44b597bd53d073f13a", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/e2e/result-cache-composer-lock/phpstan.neon b/e2e/result-cache-composer-lock/phpstan.neon new file mode 100644 index 00000000000..39e46c7dc93 --- /dev/null +++ b/e2e/result-cache-composer-lock/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 5 + tmpDir: tmp + paths: + - src diff --git a/e2e/result-cache-composer-lock/src/Foo.php b/e2e/result-cache-composer-lock/src/Foo.php new file mode 100644 index 00000000000..08f4a838da0 --- /dev/null +++ b/e2e/result-cache-composer-lock/src/Foo.php @@ -0,0 +1,13 @@ +packageDependencyResolver->getChangedComposerPackages($data['meta'], $meta) : null; - if ($changedPackages === null || $changedPackages === []) { + if ($changedPackages === null) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache not used because the metadata do not match: ' . implode(', ', $diffs)); } @@ -296,18 +297,29 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } - 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])) { - $packageSeededFiles[] = $packageDependentFile; - break; + if ($changedPackages === []) { + // The Composer lock/installed metadata changed but no installed package's version or + // reference did (e.g. a composer.lock regenerated with different formatting or dist/time + // metadata, common in CI where composer.lock is not committed). Nothing analysis-relevant + // changed, so keep the restored cache and fall through to the normal incremental analysis + // instead of re-analysing everything. + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Composer metadata changed but no package versions changed; keeping the result cache.'); + } + } else { + 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])) { + $packageSeededFiles[] = $packageDependentFile; + break; + } } } } From a82d77d5b996ac9731ce0d9bd58836584af75132 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 22 Jul 2026 16:36:29 +0200 Subject: [PATCH 2/3] Cover incremental reanalysis and package-update invalidation in result-cache e2e Addresses review feedback on the composer-lock result cache tests: - result-cache-composer-lock: after the lock-only change (cache kept, 0 files), also change the content hash together with a single source file and assert "Result cache restored. 1 file will be reanalysed." so the kept cache still reanalyses genuinely changed files. - result-cache-package-update: a new e2e where a source file depends on psr/log and the package's recorded reference in vendor/composer/installed.php changes. Asserts the cache is invalidated only for files depending on that package (the package-granular path), not for the whole project. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 21 ++++++ e2e/result-cache-package-update/.gitignore | 1 + e2e/result-cache-package-update/composer.json | 9 +++ e2e/result-cache-package-update/composer.lock | 69 +++++++++++++++++++ e2e/result-cache-package-update/phpstan.neon | 5 ++ e2e/result-cache-package-update/src/Foo.php | 19 +++++ .../tmp/.gitignore | 2 + 7 files changed, 126 insertions(+) create mode 100644 e2e/result-cache-package-update/.gitignore create mode 100644 e2e/result-cache-package-update/composer.json create mode 100644 e2e/result-cache-package-update/composer.lock create mode 100644 e2e/result-cache-package-update/phpstan.neon create mode 100644 e2e/result-cache-package-update/src/Foo.php create mode 100644 e2e/result-cache-package-update/tmp/.gitignore diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4b92052d15d..3a905bafc74 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -392,6 +392,27 @@ jobs: echo "$OUTPUT" ../bashunit -a contains 'Composer metadata changed but no package versions changed; keeping the result cache.' "$OUTPUT" ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + # Change the content hash again AND edit a single source file: the cache is still kept + # (no package changed), and only the one changed file is reanalysed. + printf '\n' >> src/Foo.php + jq '.["content-hash"]="1111111111111111111111111111111111111111"' composer.lock > composer.lock.new + mv composer.lock.new composer.lock + OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv") + echo "$OUTPUT" + ../bashunit -a contains 'Composer metadata changed but no package versions changed; keeping the result cache.' "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + - script: | + cd e2e/result-cache-package-update + composer install + ../../bin/phpstan analyse + # Simulate a single Composer package being updated by changing its recorded reference in + # vendor/composer/installed.php. The cache must be invalidated only for files depending on + # that package (src/Foo.php uses psr/log), not for the whole project. + php -r '$f = "vendor/composer/installed.php"; $d = require $f; $d["versions"]["psr/log"]["reference"] = "0000000000000000000000000000000000000000"; file_put_contents($f, "=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/e2e/result-cache-package-update/phpstan.neon b/e2e/result-cache-package-update/phpstan.neon new file mode 100644 index 00000000000..39e46c7dc93 --- /dev/null +++ b/e2e/result-cache-package-update/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 5 + tmpDir: tmp + paths: + - src diff --git a/e2e/result-cache-package-update/src/Foo.php b/e2e/result-cache-package-update/src/Foo.php new file mode 100644 index 00000000000..7b8fb75a8c1 --- /dev/null +++ b/e2e/result-cache-package-update/src/Foo.php @@ -0,0 +1,19 @@ +logger->info('hello'); + } + +} diff --git a/e2e/result-cache-package-update/tmp/.gitignore b/e2e/result-cache-package-update/tmp/.gitignore new file mode 100644 index 00000000000..125e34294bf --- /dev/null +++ b/e2e/result-cache-package-update/tmp/.gitignore @@ -0,0 +1,2 @@ +* +!.* From 9ce1fc9dce4e565ce2d8f1a30cb2eb76308961e6 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 22 Jul 2026 17:14:51 +0200 Subject: [PATCH 3/3] Do not commit composer.lock in the result-cache e2e fixtures The real-world case that motivated this (a framework whose CI regenerates the lock every job) gitignores composer.lock, so the fixtures shouldn't ship one either. `composer install` in the e2e step regenerates the lock from composer.json before the test runs, so the reproduction is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/result-cache-composer-lock/.gitignore | 1 + e2e/result-cache-composer-lock/composer.lock | 18 ----- e2e/result-cache-package-update/.gitignore | 1 + e2e/result-cache-package-update/composer.lock | 69 ------------------- 4 files changed, 2 insertions(+), 87 deletions(-) delete mode 100644 e2e/result-cache-composer-lock/composer.lock delete mode 100644 e2e/result-cache-package-update/composer.lock diff --git a/e2e/result-cache-composer-lock/.gitignore b/e2e/result-cache-composer-lock/.gitignore index 61ead86667c..de4a392c331 100644 --- a/e2e/result-cache-composer-lock/.gitignore +++ b/e2e/result-cache-composer-lock/.gitignore @@ -1 +1,2 @@ /vendor +/composer.lock diff --git a/e2e/result-cache-composer-lock/composer.lock b/e2e/result-cache-composer-lock/composer.lock deleted file mode 100644 index e512483dbfe..00000000000 --- a/e2e/result-cache-composer-lock/composer.lock +++ /dev/null @@ -1,18 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "432b93b35c547c44b597bd53d073f13a", - "packages": [], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, - "plugin-api-version": "2.9.0" -} diff --git a/e2e/result-cache-package-update/.gitignore b/e2e/result-cache-package-update/.gitignore index 61ead86667c..de4a392c331 100644 --- a/e2e/result-cache-package-update/.gitignore +++ b/e2e/result-cache-package-update/.gitignore @@ -1 +1,2 @@ /vendor +/composer.lock diff --git a/e2e/result-cache-package-update/composer.lock b/e2e/result-cache-package-update/composer.lock deleted file mode 100644 index b5a1eba4bfe..00000000000 --- a/e2e/result-cache-package-update/composer.lock +++ /dev/null @@ -1,69 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "d74d8c726d07cc626585dce3cf2b1deb", - "packages": [ - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" - } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, - "plugin-api-version": "2.9.0" -}