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
Let a rule declare that its error depends on a file, and watch includ…
…ed files

The file named in a require() is not analysed, not scanned and not
autoloadable, so no hash of it existed anywhere and neither direction of the
check was noticed: deleting it kept the cached clean result, and creating it
kept the cached error. It holds no symbol to reflect, and the dependency
graph is built out of reflected symbols, so nothing about it could be
recorded.

Two things were missing, and both are about a path rather than a symbol.

RuleErrorBuilder::fileDependency() lets a rule say that its error depends on
a file, through the new FileDependenciesRuleError interface and the rule
error classes generated for it. FileAnalyser records those paths as ordinary
file dependencies, so any rule whose verdict comes from outside the analysed
file - a path that must exist, a data file read during analysis - can be
invalidated properly. RequireFileExistsRule declares every path the include
could have resolved to, and creating any one of them clears the error.

The other direction needs no error to hang on: when the file is there, the
rule says nothing, and the include still has to be watched so that deleting
it is noticed. DependencyResolver records the resolved path for every
include, and NodeDependencies carries such paths next to the reflections.
The path resolution both of them need - the working directory, the include
path, the directory of the analysed file, with the trait case - moved into
IncludedFilePathResolver.

A watched path that does not exist is recorded with an empty hash, which no
real hash can be, so the cache can hold it without reading it. restore()
re-analyses the files naming it when it appears, keeps watching while it
stays missing, and keeps the deletion behaviour it already had.

e2e/result-cache-require-deleted covers both directions.
  • Loading branch information
ondrejmirtes committed Sep 3, 2026
commit ee42a974a7e00162aa8388bd3407fbd6065f714f
15 changes: 15 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,21 @@ jobs:
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw")
echo "$OUTPUT"
../bashunit -a contains 'User.php:10:Method ResultCacheE2EImportType\User::doUser() should return int but returns string.' "$OUTPUT"
- script: |

@ondrejmirtes ondrejmirtes Sep 3, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discard this commit altogether ( Record which files hold an error from a rule built on collected data )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped from this PR. Reproduction moved to #6358 (the failing test on its own, no fix) and described in phpstan/phpstan#15162. This branch is also rebased on 2.2.x now that #6356 is in - the conflict was the two e2e blocks landing next to each other.

cd e2e/result-cache-require-deleted
../../bin/phpstan analyse
# The file named in require() is not analysed, not scanned and not autoloadable, so no hash
# of it exists anywhere in the cache. Deleting it has to be noticed all the same: the rule
# that checks the path reports an error the moment it is gone.
rm config/app.php
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw")
echo "$OUTPUT"
../bashunit -a contains 'Foo.php:10:Path in require() "config/app.php" is not a file or it does not exist.' "$OUTPUT"
# And putting it back has to clear the error again.
printf '<?php declare(strict_types = 1);\n\nreturn [];\n' > config/app.php
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv --error-format raw")
echo "$OUTPUT"
../bashunit -a not_contains 'is not a file or it does not exist' "$OUTPUT"
- script: |
cd e2e/result-cache-extension-update
composer install
Expand Down
3 changes: 3 additions & 0 deletions e2e/result-cache-require-deleted/config/app.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php declare(strict_types = 1);

return [];
5 changes: 5 additions & 0 deletions e2e/result-cache-require-deleted/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
level: 0
tmpDir: tmp
paths:
- src
13 changes: 13 additions & 0 deletions e2e/result-cache-require-deleted/src/Foo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace ResultCacheE2ERequireDeleted;

class Foo
{

public function load(): mixed
{
return require 'config/app.php';
}

}
2 changes: 2 additions & 0 deletions e2e/result-cache-require-deleted/tmp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.*
12 changes: 12 additions & 0 deletions src/Analyser/FileAnalyserCallback.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use PHPStan\Node\InClassNode;
use PHPStan\Node\InTraitNode;
use PHPStan\Parser\Parser;
use PHPStan\Rules\FileDependenciesRuleError;
use PHPStan\Rules\Registry as RuleRegistry;
use function array_keys;
use function get_class;
Expand Down Expand Up @@ -160,6 +161,14 @@ public function __invoke(Node $node, Scope $scope): void
}

foreach ($ruleErrors as $ruleError) {
if ($ruleError instanceof FileDependenciesRuleError) {
// The rule says its verdict depends on files the dependency graph cannot know about,
// because they hold no symbol PHPStan reflects - a path named in the code, for one.
foreach ($ruleError->getFileDependencies() as $fileDependency) {
$this->fileDependencies[] = $fileDependency;
}
}

$error = $this->ruleErrorTransformer->transform($ruleError, $scope, $parserNodes, $node);

if ($error->canBeIgnored()) {
Expand Down Expand Up @@ -220,6 +229,9 @@ public function __invoke(Node $node, Scope $scope): void
foreach ($dependencies->getFileDependencies($scope->getFile(), $this->analysedFiles) as $dependentFile) {
$this->fileDependencies[] = $dependentFile;
}
foreach ($dependencies->getFilePaths() as $dependentFile) {
$this->fileDependencies[] = $dependentFile;
}
$nonAnalysedDependencies = $dependencies->getNonAnalysedDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver);
foreach ($nonAnalysedDependencies['files'] as $dependentFile) {
$this->fileDependencies[] = $dependentFile;
Expand Down
48 changes: 44 additions & 4 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@ final class ResultCacheManager
*/
private const EXTENSIONS_NOT_INVALIDATING_CACHE = ['xdebug', 'blackfire', 'phpstan_turbo'];

private const CACHE_VERSION = 'v17-phpVersionForAnalysis';
private const CACHE_VERSION = 'v18-missingFileDependencies';

/**
* The recorded hash of a dependency that does not exist. A rule can depend on a path rather than on
* a symbol - the file named in a require() - and such a dependency has to be watched while it is
* missing, so that creating it re-analyses the file that named it. No real hash is empty.
*/
private const MISSING_FILE_HASH = '';

private const SCANNED_FILE_APPEARED = 'appeared';
private const SCANNED_FILE_EDITED = 'edited';
Expand Down Expand Up @@ -662,7 +669,28 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
$dependentFiles = $notAnalysedFileData['dependentFiles'];
$usedTraitDependentFiles = $notAnalysedFileData['usedTraitDependentFiles'] ?? [];

if (is_file($notAnalysedFile)) {
$wasMissing = $notAnalysedFileData['fileHash'] === self::MISSING_FILE_HASH;
if (!is_file($notAnalysedFile) && $wasMissing) {
// A path that was already missing when the cache was written, and still is: nothing
// changed, but it stays watched so that creating it re-analyses the files naming it.
$invertedDependenciesToReturn[$notAnalysedFile] = $dependentFiles;
if (count($usedTraitDependentFiles) > 0) {
$invertedUsedTraitDependenciesToReturn[$notAnalysedFile] = $usedTraitDependentFiles;
}

continue;
}

if (is_file($notAnalysedFile) && $wasMissing) {
// It exists now. Whether it holds any symbol is beside the point - a file that was named
// and was not there is now there, and that alone changes what the analysis says.
$invertedDependenciesToReturn[$notAnalysedFile] = $dependentFiles;
if (count($usedTraitDependentFiles) > 0) {
$invertedUsedTraitDependenciesToReturn[$notAnalysedFile] = $usedTraitDependentFiles;
}

$dependentFiles = array_merge($dependentFiles, $usedTraitDependentFiles);
} elseif (is_file($notAnalysedFile)) {
// Not analysed but still on disk: a scanned file, or another project file the analysed
// code depends on. Its edges and exported nodes are not carried over by the loop above
// (that one only walks the analysed files), so they are preserved here.
Expand Down Expand Up @@ -1314,7 +1342,7 @@ private function save(
foreach ($fileDependencies as $fileDep) {
if (!array_key_exists($fileDep, $invertedDependencies)) {
$invertedDependencies[$fileDep] = [
'fileHash' => $currentFileHashes[$fileDep] ?? $this->getFileHash($fileDep),
'fileHash' => $currentFileHashes[$fileDep] ?? $this->getDependencyFileHash($fileDep),
'dependentFiles' => [],
];
unset($filesNoOneIsDependingOn[$fileDep]);
Expand All @@ -1327,7 +1355,7 @@ private function save(
foreach ($fileUsedTraitDependencies as $usedTraitFileDep) {
if (!array_key_exists($usedTraitFileDep, $invertedDependencies)) {
$invertedDependencies[$usedTraitFileDep] = [
'fileHash' => $currentFileHashes[$usedTraitFileDep] ?? $this->getFileHash($usedTraitFileDep),
'fileHash' => $currentFileHashes[$usedTraitFileDep] ?? $this->getDependencyFileHash($usedTraitFileDep),
'dependentFiles' => [],
'usedTraitDependentFiles' => [],
];
Expand Down Expand Up @@ -1800,6 +1828,18 @@ private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): a
]);
}

/**
* The hash of a file that is depended on, which is allowed not to exist - see MISSING_FILE_HASH.
*/
private function getDependencyFileHash(string $path): string
{
if (!is_file($path)) {
return self::MISSING_FILE_HASH;
}

return $this->getFileHash($path);
}

private function getFileHash(string $path): string
{
if (array_key_exists($path, $this->fileReplacements)) {
Expand Down
18 changes: 17 additions & 1 deletion src/Dependency/DependencyResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PHPStan\Broker\FunctionNotFoundException;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\File\FileHelper;
use PHPStan\File\IncludedFilePathResolver;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Node\FunctionCallableNode;
use PHPStan\Node\InClassMethodNode;
Expand All @@ -35,6 +36,7 @@
use function array_merge;
use function count;
use function in_array;
use function is_file;

#[AutowiredService]
final class DependencyResolver
Expand All @@ -45,6 +47,7 @@ final class DependencyResolver

public function __construct(
private FileHelper $fileHelper,
private IncludedFilePathResolver $includedFilePathResolver,
private ReflectionProvider $reflectionProvider,
private ExportedNodeResolver $exportedNodeResolver,
private FileTypeMapper $fileTypeMapper,
Expand All @@ -55,6 +58,7 @@ public function __construct(
public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
{
$dependenciesReflections = [];
$dependenciesFilePaths = [];

if ($node instanceof Node\Stmt\Class_) {
if (isset($node->namespacedName)) {
Expand Down Expand Up @@ -467,6 +471,18 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
$this->addClassToDependencies($referencedClass, $dependenciesReflections);
}
}
} elseif ($node instanceof Node\Expr\Include_) {
// An included file is a dependency with no symbol to reflect: nothing in it has to be
// declared for the including file's analysis to change when it is deleted.
foreach ($scope->getType($node->expr)->getConstantStrings() as $constantString) {
foreach ($this->includedFilePathResolver->resolve($constantString->getValue(), $scope) as $candidatePath) {
if (!is_file($candidatePath)) {
continue;
}

$dependenciesFilePaths[] = $candidatePath;
}
}
} elseif ($node instanceof Node\Stmt\Catch_) {
foreach ($node->types as $type) {
$this->addClassToDependencies($scope->resolveName($type), $dependenciesReflections);
Expand Down Expand Up @@ -521,7 +537,7 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
}
}

return new NodeDependencies($this->fileHelper, $dependenciesReflections, $this->exportedNodeResolver->resolve($scope->getFile(), $node));
return new NodeDependencies($this->fileHelper, $dependenciesReflections, $this->exportedNodeResolver->resolve($scope->getFile(), $node), $dependenciesFilePaths);
}

public function resolveUsedTraitDependencies(InClassNode $inClassNode): NodeDependencies
Expand Down
13 changes: 13 additions & 0 deletions src/Dependency/NodeDependencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,28 @@ final class NodeDependencies

/**
* @param array<int, ClassReflection|FunctionReflection|ConstantReflection> $reflections
* @param list<string> $filePaths files depended on directly, by path rather than through a symbol
*/
public function __construct(
private FileHelper $fileHelper,
private array $reflections,
private ?RootExportedNode $exportedNode,
private array $filePaths = [],
)
{
}

/**
* Files this node depends on by path: an included file holds no symbol to reflect, but deleting it
* changes what the analysis says about the file including it.
*
* @return list<string>
*/
public function getFilePaths(): array
{
return $this->filePaths;
}

/**
* @return array<int, ClassReflection|FunctionReflection|ConstantReflection>
*/
Expand Down
73 changes: 73 additions & 0 deletions src/File/IncludedFilePathResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php declare(strict_types = 1);

namespace PHPStan\File;

use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use function array_merge;
use function array_values;
use function dirname;
use function explode;
use function get_include_path;
use const PATH_SEPARATOR;

/**
* The absolute paths an include/require of a given path could resolve to, in the order PHP would try
* them. `stream_resolve_include_path()` cannot be used: it resolves against the running script, and
* what matters is the analysed file.
*/
#[AutowiredService]
final class IncludedFilePathResolver
{

public function __construct(
#[AutowiredParameter]
private string $currentWorkingDirectory,
private FileHelper $fileHelper,
)
{
}

/**
* @return list<string>
*/
public function resolve(string $path, Scope $scope): array
{
$directories = array_merge(
[$this->currentWorkingDirectory],
explode(PATH_SEPARATOR, get_include_path()),
[dirname($this->getScopeFile($scope))],
);

$candidatePaths = [];
foreach ($directories as $directory) {
if ($directory === '') {
continue;
}

$candidatePath = (new FileHelper($directory))->absolutizePath($path);
$candidatePaths[$candidatePath] = $candidatePath;
}

return array_values($candidatePaths);
}

/**
* Both `__DIR__` and the "calling script's own directory" fallback of a relative include are
* resolved at compile time, so inside a trait they point at the file the trait is declared in - not
* at the file of the class that uses it, which is what Scope::getFile() returns in a trait context.
*/
private function getScopeFile(Scope $scope): string
{
if ($scope->isInTrait()) {
$traitFileName = $scope->getTraitReflection()->getFileName();
if ($traitFileName !== null) {
return $this->fileHelper->normalizePath($traitFileName);
}
}

return $scope->getFile();
}

}
26 changes: 26 additions & 0 deletions src/Rules/FileDependenciesRuleError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules;

/**
* An error whose existence depends on files other than the one being analysed - a path named in the
* code that has to exist, a data file read at analysis time. The result cache watches the files
* listed here and re-analyses the file the error is reported in when one of them appears, changes or
* is deleted, which nothing else would do: the dependency graph is built from reflected symbols, and
* a path is not a symbol.
*
* Paths must be absolute and are watched whether or not they exist, so an error about a missing file
* is cleared by that file being created.
*
* @api
* @api-do-not-implement
*/
interface FileDependenciesRuleError extends RuleError
{

/**
* @return list<string>
*/
public function getFileDependencies(): array;

}
Loading
Loading