Repository navigation
PHPDoc of a class declared inside a function is lost once an analysis of other paths has cached that file's name scopes #15475
Description
Bug report
A file's cached PHPDoc name scopes (FileTypeMapper, cache key ftm-<file>) can be built from a parse without function bodies. Later full analyses then keep using them. After that, the PHPDoc of any class declared inside a function or method body in that file resolves to nothing, until the file's content changes. Typical cases are an anonymous class returned by a helper, or a test double. clear-result-cache does not help.
How it happens, as far as I can tell from the source:
- An analysis run on only some paths parses the other files with
defaultAnalysisParser, aPathRoutingParser. Files outsideanalysedFilesgo tocurrentPhpVersionSimpleParser(CleaningParser), whoseCleaningVisitorstrips function, method and closure bodies. - If that run is the first to need such a file's name scopes,
FileTypeMapper::getNameScopeMap()builds them from the cleaned AST. The scopes of a class declared inside a function body are therefore missing. The map is saved withcache->save('ftm-' . $fileName, 'v7-…', [$nameScopeMap, $filesWithHashes]). - The entry is validated only against the files' content hashes, and nothing records that it came from the simple parser. So a later run that analyses the file uses the incomplete map as well.
getNameScope()throwsNameScopeAlreadyBeingCreatedExceptionwhen the key is missing.getResolvedPhpDoc()catches it and returnsResolvedPhpDocBlock::createEmpty(), so the PHPDoc is silently dropped and no error points to the cache.clear-result-cacheremoves the result cache but nottmpDir/cache/PHPStan, so the wrong result comes back after clearing it.
Partial runs are common: on changed files, in pre-commit hooks, from editors, or by coding agents. In our project (Laravel and Pest, with test helpers in tests/Pest.php), a run on app plus a few test files was enough to break every later phpstan analyse in that checkout. CI passes, because it always starts with an empty tmpDir and analyses all paths at once.
Reproduced with PHPStan 2.3.1 and 2.2.15 on PHP 8.5.10 (NTS, macOS arm64), with Turbo loaded and with PHPSTAN_TURBO=0, and on 2.2.15 also in single-process mode (--debug).
Code snippet that reproduces the problem
This can't be shown in the playground, because it needs two runs that share a cache. Four files:
phpstan.neon
parameters:
level: max
tmpDir: tmp
paths:
- src
scanFiles:
- src/helpers.phpsrc/helpers.php
<?php
declare(strict_types=1);
interface Inbox
{
/** @return list<string> */
public function search(string $words): array;
}
/**
* @param Closure(string): list<string> $search
*/
function inbox(Closure $search): Inbox
{
return new readonly class($search) implements Inbox
{
/**
* @param Closure(string): list<string> $search
*/
public function __construct(private Closure $search) {}
public function search(string $words): array
{
return ($this->search)($words);
}
};
}src/usage.php
<?php
declare(strict_types=1);
function usage(): void
{
inbox(static fn (string $words): array => [$words])->search('a b');
}run.sh (PHPSTAN=vendor/bin/phpstan sh run.sh)
#!/bin/sh
set -u
cd "$(dirname "$0")"
rm -rf tmp
echo '== 1. full analysis, empty cache'
"$PHPSTAN" analyse --no-progress
echo '== 2. empty cache, analyse only src/usage.php first'
rm -rf tmp
"$PHPSTAN" analyse --no-progress src/usage.php
echo '== 3. clear the result cache, then the same full analysis as in 1'
"$PHPSTAN" clear-result-cache >/dev/null
"$PHPSTAN" analyse --no-progressOutput:
== 1. full analysis, empty cache
[OK] No errors
== 2. empty cache, analyse only src/usage.php first
[OK] No errors
== 3. clear the result cache, then the same full analysis as in 1
------ -----------------------------------------------------------------------
Line helpers.php
------ -----------------------------------------------------------------------
25 Method Inbox@anonymous/helpers.php:16::search() should return list<st
ring> but returns mixed.
🪪 return.type
💡 mixed is not a list.
------ -----------------------------------------------------------------------
[ERROR] Found 1 error
Removing tmp/cache/PHPStan and clearing the result cache brings back "No errors".
Expected output
Step 3 should report the same as step 1, "No errors". The result of a full analysis shouldn't depend on which paths an earlier run analysed.
Possible directions, if useful:
- Record in the cached entry (or its key) whether the map came from the simple parser, and rebuild it from the rich parser when the file is analysed.
- Have
clear-result-cachealso clear the name-scope entries. - Don't silently turn a missing name scope into an empty PHPDoc.
Related: #15037 (Turbo dropping comments) also cached PHPDoc-less name scopes. The v6 key bump discarded those entries, but this path still writes incomplete ones.
Did PHPStan help you today? Did it make you happy in any way?
No response
Activity
mergeable commentedon Oct 13, 2026
This bug report is missing a link to reproduction at phpstan.org/try.
It will most likely be closed after manual review.
drewmt commentedon Oct 13, 2026
I reproduced the partial-analysis cache case and opened a fix with a regression test: phpstan/phpstan-src#6739. It also rebuilds existing incomplete name-scope cache entries.