#15231·phpstan

Analysis runs on a body-stripped AST: phantom return.missing / expr.resultUnused since 2.2.10

Author: mplodowskiCreated Sep 14, 2026Updated Sep 15, 2026

Bug report

PHPStan analyses some files from a body-stripped AST — the CleaningVisitor output meant for files that are not analysed. Every method of the affected class is then reported as return.missing, arrow functions and closures show up as expr.resultUnused ("on a separate line does not do anything") with Undefined variable for their outer scope, and some errors carry line: -1. The code is correct.

Started with 2.2.10 (2.2.9 is clean, bisected with phars); still there in 2.2.14.

Reproduction

Two files in an otherwise empty directory — no Composer, no bootstrap file, no extensions:

phpstan.neon

neon
parameters:
    level: 7
    paths:
        - src

src/Result.php

php
<?php

namespace Renatio\SeoManager\Classes\Analysis;

class Result
{
    public const STATUS_OK = 'ok';

    public const STATUS_WARNING = 'warning';

    public const STATUS_PROBLEM = 'problem';

    /**
     * A check that has nothing to look at - no keyword chosen, no body to read - is not a failure.
     * It leaves the score entirely, or every page without a keyword would show a red 30.
     */
    public const STATUS_SKIPPED = 'skipped';

    public function __construct(
        protected string $key,
        protected string $status,
        protected string $message,
        protected int $weight = 0,
    ) {
    }

    public function key(): string
    {
        return $this->key;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function message(): string
    {
        return $this->message;
    }

    public function weight(): int
    {
        return $this->weight;
    }

    public function label(): string
    {
        return __('renatio.seomanager::lang.analysis.' . $this->key);
    }

    public function counts(): bool
    {
        return $this->status !== self::STATUS_SKIPPED;
    }

    public function isPassed(): bool
    {
        return $this->status === self::STATUS_OK;
    }
}

src/Report.php

php
<?php

namespace Renatio\SeoManager\Classes\Analysis;

/**
 * What the analysis found, and the one number people compare against other SEO plugins. The number
 * is a share of the weight that applied: a check with nothing to look at leaves the denominator, so
 * a page that simply has no keyword yet is not reported as a failing one.
 */
class Report
{
    public const BAND_GOOD = 'good';

    public const BAND_FAIR = 'fair';

    public const BAND_POOR = 'poor';

    /** Nothing was measured, so nothing is being said about the page. */
    public const BAND_NONE = 'none';

    protected const GOOD_FROM = 80;

    protected const FAIR_FROM = 50;

    /** A passed check counts whole, a warning half: it is a page that works but could work better. */
    protected const WARNING_SHARE = 0.5;

    /**
     * @param  array<int, Result>  $results
     */
    public function __construct(protected array $results)
    {
    }

    /**
     * @return array<int, Result>
     */
    public function results(): array
    {
        return $this->results;
    }

    /**
     * @return array<int, Result>
     */
    public function applied(): array
    {
        return array_values(array_filter($this->results, fn (Result $result) => $result->counts()));
    }

    public function score(): ?int
    {
        $total = 0;
        $earned = 0.0;

        foreach ($this->applied() as $result) {
            $total += $result->weight();
            $earned += match ($result->status()) {
                Result::STATUS_OK => $result->weight(),
                Result::STATUS_WARNING => $result->weight() * self::WARNING_SHARE,
                default => 0,
            };
        }

        return $total === 0 ? null : (int) round($earned / $total * 100);
    }

    public function band(): string
    {
        $score = $this->score();

        if ($score === null) {
            return self::BAND_NONE;
        }

        return match (true) {
            $score >= self::GOOD_FROM => self::BAND_GOOD,
            $score >= self::FAIR_FROM => self::BAND_FAIR,
            default => self::BAND_POOR,
        };
    }

    public function passed(): int
    {
        return count(array_filter($this->applied(), fn (Result $result) => $result->isPassed()));
    }
}
$ phpstan analyse -c phpstan.neon
 ------ ---------------------------------------------------------------------------
  Line   src/Result.php
 ------ ---------------------------------------------------------------------------
  27     Method ...\Result::key() should return string but return statement is missing.
  32     Method ...\Result::status() should return string but return statement is missing.
  37     Method ...\Result::message() should return string but return statement is missing.
  42     Method ...\Result::weight() should return int but return statement is missing.
  47     Method ...\Result::label() should return string but return statement is missing.
  52     Method ...\Result::counts() should return bool but return statement is missing.
  57     Method ...\Result::isPassed() should return bool but return statement is missing.
 ------ ---------------------------------------------------------------------------

Expected: the single error a healthy run reports — src/Result.php:49: Method ...\Result::label() should return string but returns array|string. (Result.php analysed alone gives exactly that). Neither file alone produces a single return.missing; the two together produce all seven. Reproducible from a cold tmpDir on every run, independent of the order of paths.

Why it looks like a stripped AST

The symptom set matches CleaningVisitor exactly: it drops method bodies and hoists closures/arrow functions into standalone Expression statements. Two observations back that up:

  1. In the broken state the real error inside the method body disappears (the one in Result::label()); it comes back the moment the file is analysed normally.
  2. In a larger project the same run also reports Undefined variable: $x plus Anonymous function has an unused use $x for a closure that is perfectly fine — i.e. the closure was lifted out of the method that defined $x.

conf/parsers.neon wires

defaultAnalysisParser: CachedParser(originalParser: pathRoutingParser)

so the AST cache sits outside PathRoutingParser, is keyed by the file's source code, and carries no record of which parser produced the entry — only parseString() results are marked (parsedByString). Once a file has been parsed through the "not analysed" route (CleaningParser), analysing that same file returns the cleaned AST from the cache.

Ruled out

Suspect Result
bootstrap files reproduces with none
third-party extensions (larastan, pest) reproduces on a bare level: 7 config
phpstan_turbo extension reproduces with -d phpstan.restarted=1 (main process without it)
parallelism reproduces with --debug
result cache fresh tmpDir on every run
PHP version same on 8.5.8 and 8.4.23

Note on how brittle the reproduction is

The trigger depends on the exact contents of Result.php. Appending a comment, adding or removing one method, or renaming the namespace (even to one of the same byte length) all make the phantoms disappear. That is why the files are pasted verbatim rather than trimmed — a reduced version stops reproducing.

In a real project of ~200 files the same bug currently produces 86 such errors across 13 files; there, a single edit anywhere can move which files are affected. return.missing is not ignorable, so there is no way to work around it in configuration.

Environment

  • PHPStan 2.2.14 (also 2.2.13, 2.2.10; 2.2.9 clean)
  • PHP 8.5.8, macOS arm64 (Apple silicon)