Skip to content

PHPDoc of a class declared inside a function is lost once an analysis of other paths has cached that file's name scopes #15475

Open
phpstan/phpstan-src
#6739

Description

@Victor-emil

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:

  1. An analysis run on only some paths parses the other files with defaultAnalysisParser, a PathRoutingParser. Files outside analysedFiles go to currentPhpVersionSimpleParser (CleaningParser), whose CleaningVisitor strips function, method and closure bodies.
  2. 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 with cache->save('ftm-' . $fileName, 'v7-…', [$nameScopeMap, $filesWithHashes]).
  3. 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.
  4. getNameScope() throws NameScopeAlreadyBeingCreatedException when the key is missing. getResolvedPhpDoc() catches it and returns ResolvedPhpDocBlock::createEmpty(), so the PHPDoc is silently dropped and no error points to the cache.
  5. clear-result-cache removes the result cache but not tmpDir/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.php

src/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-progress

Output:

== 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-cache also 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

  1. mergeable commented on Oct 13, 2026

    @mergeable

    This bug report is missing a link to reproduction at phpstan.org/try.

    It will most likely be closed after manual review.

  2. drewmt commented on Oct 13, 2026

    @drewmt

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions