Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Verify recorded stub file hashes when restoring the result cache
The result cache meta ran the StubFilesExtensions on every restore to
compare the stub file list. That happens before any worker starts, and
since bootstrapFiles are deferred to the workers, an extension relying
on them (larastan reads a constant its bootstrap file defines) fatals
before the analysis starts.

The stub file list is now recorded into the meta only when saving the
cache - after the analysis, when bootstrapFiles have been executed.
restore() no longer runs the extensions: it verifies the recorded
hashes and invalidates the whole cache when a stub file changed or
disappeared. An extension returning a different list on an otherwise
unchanged project is no longer detected, but a change like that comes
from changed extension code or config, which projectExtensionFiles and
the projectConfig meta already cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cAV6sztxvNeaEQ78RVFad
  • Loading branch information
ondrejmirtes and claude committed Aug 27, 2026
commit 622f8ce57c756137234e4525115ba1da8b92ec91
11 changes: 11 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,17 @@ jobs:
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan")
echo "$OUTPUT"
../bashunit -a contains 'Method StubFiles\Foo::doFoo() has no return type specified.' "$OUTPUT"
- script: |
cd e2e/bug-15120
composer install
../../bin/phpstan analyse -vvv
../../bin/phpstan analyse -vvv
patch -b stubs/Foo.stub < stub-change.patch
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse --error-format=raw")
echo "$OUTPUT"
../bashunit -a contains 'should return non-empty-string but returns string' "$OUTPUT"
mv stubs/Foo.stub.orig stubs/Foo.stub
../../bin/phpstan analyse -vvv
- script: |
cd e2e/nested-trait-use
../../bin/phpstan analyze
Expand Down
2 changes: 2 additions & 0 deletions e2e/bug-15120/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor
/composer.lock
3 changes: 3 additions & 0 deletions e2e/bug-15120/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php declare(strict_types = 1);

define('Bug15120\FRAMEWORK_VERSION', '13.26.1');
7 changes: 7 additions & 0 deletions e2e/bug-15120/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"Bug15120\\": "extension/"
}
}
}
22 changes: 22 additions & 0 deletions e2e/bug-15120/extension/FrameworkStubFilesExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types = 1);

namespace Bug15120;

use PHPStan\PhpDoc\StubFilesExtension;
use function constant;
use function version_compare;

final class FrameworkStubFilesExtension implements StubFilesExtension
{

public function getFiles(): array
{
// mimics larastan: the constant is defined by a bootstrapFiles entry
if (version_compare((string) constant('Bug15120\FRAMEWORK_VERSION'), '10.0', '<')) {
return [];
}

return [__DIR__ . '/../stubs/Foo.stub'];
}

}
12 changes: 12 additions & 0 deletions e2e/bug-15120/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
parameters:
level: 8
paths:
- src
bootstrapFiles:
- bootstrap.php

services:
-
class: Bug15120\FrameworkStubFilesExtension
tags:
- phpstan.stubFilesExtension
14 changes: 14 additions & 0 deletions e2e/bug-15120/src/Consumer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);

namespace Bug15120;

class Consumer
{

/** @return non-empty-string */
public function describe(Foo $foo): string
{
return $foo->get();
}

}
13 changes: 13 additions & 0 deletions e2e/bug-15120/src/Foo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace Bug15120;

class Foo
{

public function get(): string
{
return 'hello';
}

}
11 changes: 11 additions & 0 deletions e2e/bug-15120/stub-change.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--- stubs/Foo.stub
+++ stubs/Foo.stub
@@ -5,7 +5,7 @@
class Foo
{

- /** @return non-empty-string */
+ /** @return string */
public function get(): string
{

14 changes: 14 additions & 0 deletions e2e/bug-15120/stubs/Foo.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Bug15120;

class Foo
{

/** @return non-empty-string */
public function get(): string
{

}

}
74 changes: 72 additions & 2 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
$exportedNodesCallback = $data['exportedNodesCallback'];
$data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback());

// The stub file hashes get into the meta only at save time, after the analysis - the
// only point where the StubFilesExtensions may run, because they can rely on
// bootstrapFiles having been executed. Restoring must not run them, so the entry is
// taken out of the cached meta here; the freshly computed meta below never contains it,
// and isMetaDifferent() never sees it on either side. Instead, the recorded hashes are
// verified after the meta comparison - a changed stub file invalidates the whole cache.
// A different list coming from an extension is not detected - projectExtensionFiles
// cover that.
$cachedStubFiles = [];
if (array_key_exists('stubFiles', $data['meta']) && is_array($data['meta']['stubFiles'])) {
/** @var array<string, string> $cachedStubFiles */
$cachedStubFiles = $data['meta']['stubFiles'];
unset($data['meta']['stubFiles']);
}

$meta = $this->getMeta($allAnalysedFiles, $projectConfigArray);
// absolutized above, so it is always present here
$packageDependencies = $data['packageDependencies'];
Expand Down Expand Up @@ -485,6 +500,57 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
);
}

foreach ($cachedStubFiles as $stubFile => $stubFileHash) {
if (!is_file($stubFile)) {
if ($output->isVeryVerbose()) {
$output->writeLineFormatted(sprintf('Result cache not used because stub file %s was not found.', $stubFile));
}
return new ResultCache(
filesToAnalyse: $allAnalysedFiles,
fullAnalysis: true,
lastFullAnalysisTime: time(),
meta: $meta,
errors: [],
locallyIgnoredErrors: [],
linesToIgnore: [],
unmatchedLineIgnores: [],
collectedData: [],
dependencies: [],
usedTraitDependencies: [],
packageDependencies: [],
exportedNodes: [],
projectExtensionFiles: [],
currentFileHashes: $currentFileHashes,
);
}

if ($this->getFileHash($stubFile) === $stubFileHash) {
continue;
}

if ($output->isVeryVerbose()) {
$output->writeLineFormatted(sprintf('Result cache not used because stub file %s hash does not match.', $stubFile));
}

return new ResultCache(
filesToAnalyse: $allAnalysedFiles,
fullAnalysis: true,
lastFullAnalysisTime: time(),
meta: $meta,
errors: [],
locallyIgnoredErrors: [],
linesToIgnore: [],
unmatchedLineIgnores: [],
collectedData: [],
dependencies: [],
usedTraitDependencies: [],
packageDependencies: [],
exportedNodes: [],
projectExtensionFiles: [],
currentFileHashes: $currentFileHashes,
);
}

$invertedDependencies = $data['dependencies'];
$deletedFiles = array_fill_keys(array_keys($invertedDependencies), true);
$filesToAnalyse = [];
Expand All @@ -504,7 +570,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
$filteredExportedNodes = [];
$newFileAppeared = false;

foreach (array_keys($this->getStubFiles()) as $stubFile) {
foreach (array_keys($cachedStubFiles) as $stubFile) {
if (!array_key_exists($stubFile, $errors)) {
continue;
}
Expand Down Expand Up @@ -1254,6 +1320,11 @@ private function save(

ksort($exportedNodes);

// The only point where the StubFilesExtensions may run: the analysis is over, bootstrapFiles
// have been executed, so the extensions can rely on them. restore() reads these hashes back
// instead of running the extensions again.
$meta['stubFiles'] = $this->getStubFiles();

// Store paths relative to the anchor so the cache survives a change of the project's absolute
// path prefix (a fresh CI checkout dir, a git worktree). projectConfig inside $meta is already a
// Neon-encoded string here (encoded in process()), so it is relativized at the array level before
Expand Down Expand Up @@ -1525,7 +1596,6 @@ private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): a
'composerInstalled' => $this->getComposerInstalled(),
'executedFilesHashes' => $this->getExecutedFileHashes(),
'phpExtensions' => $extensions,
'stubFiles' => $this->getStubFiles(),
'level' => $this->usedLevel,
];
}
Expand Down