PHPStan の JSON 出力はノイズだらけ。ベースライン差分付きフォーマッタを書いた

PHPStan の JSON 出力はノイズだらけ。ベースライン差分付きフォーマッタを書いた

PHPStan 自体は素晴らしい。しかし人間向け出力は CI ログから流れて構造が失われ、JSON 出力はストレージ形式のような形をしており、ベースラインは .neon ファイルで GitHub の差分ビューアが上手くレンダリングできない。この記事は、その 3 つの問題を解決する 500 行の PHP CLI について。

📦 GitHub: https://github.com/sen-ltd/phpstan-report

Screenshot

課題

実プロジェクトの CI で PHPStan を回したことがあれば、こんな出力を見たことがあるだろう:

 ------ ---------------------------------------------------------
  Line   src/Service/UserService.php
 ------ ---------------------------------------------------------
  42     Call to an undefined method ...
  58     Parameter $user of method ... has invalid type ...
 ------ ---------------------------------------------------------

400 件の指摘があれば 1600 行のパイプ装飾テーブルがサマリーの前にスクロールする。PR コメントに貼れないし、有用なシグナルを grep できないし、ログを開いた人は最初の画面だけ読んでタブを閉じる。

--error-format=json にするのは半分だけの解決策。JSON の形はこうなっている:

{
  "totals": { "errors": 7, "file_errors": 7 },
  "files": {
    "/abs/path/to/src/Service/UserService.php": {
      "errors": 3,
      "messages": [{ "message": "...", "line": 42, "identifier": "method.notFound" }]
    }
  }
}

ストレージ形式であって、レポート形式ではない。エラーは絶対パスをキーとするマップの下にネストされ、重大度フィールドもない。

そこで phpstan-report を書いた。--error-format=json を食べて、4 つの異なる形式 ── 人間向けカラーターミナル、Markdown テーブル、スリム JSON、GitHub Actions アノテーション ── を出力し、さらにシンプルなマッチングアルゴリズムによるベースライン差分も提供する。

設計

3 つのコンポーネント:

  1. Parser ── PHPStan の JSON をフラットな errors[] リストに正規化。各エントリに file, line, message, identifier, ignorable, そして追加の level フィールド(error, warning, info)。
  2. Baseline ── 前回の実行結果と (file, message) で比較。新規エラー、解消済みエラー、共通エラー。
  3. 4 つのフォーマッタ ── HumanFormatter、MarkdownFormatter、JsonFormatter、GithubFormatter。

分類ステップ

PHPStan の JSON 出力にはメッセージごとの重大度がない。すべてが「エラー」。しかし急いでいる人間向けのレポートでは、「本物のバグ 2 件 + 未使用 import 200 件」は「本物のバグ 202 件」と違って読めなければならない。

分類器は意図的に単純:

private const FATAL_PATTERNS = [
    '/Parameter .+ of method .+ has invalid type/i',
    '/Call to an undefined method /i',
    '/Undefined variable: /i',
    '/Access to an undefined property /i',
    // ...
];

private function classify(string $message, bool $ignorable): string
{
    foreach (self::FATAL_PATTERNS as $pat) {
        if (preg_match($pat, $message) === 1) {
            return self::LEVEL_ERROR;
        }
    }
    return $ignorable ? self::LEVEL_INFO : self::LEVEL_WARNING;
}

8 つの正規表現パターンと ignorable フラグによるフォールスルー。これで得られるサマリー行:

“4 errors, 0 warnings, 3 info — 7 findings across 3 files”

vs PHPStan ネイティブ:

“Found 7 errors”

同じデータ。違う会話。

ベースライン差分:行番号ずれ問題の解消

PHPStan のベースラインはファイル、メッセージ、行番号でマッチする。リファクタリングで 5 行ずれると、ベースラインが合わなくなり、同じ日に「新規エラー 5 件、解消 5 件」という幽霊差分が出る。

phpstan-report(file, message) のみでマッチする。行番号は表示用に保持するが、ベースラインキーには使わない:

private function keyOf(array $e): string
{
    return $e['file'] . "\x00" . $e['message'];
}

クレジット消費型ウォークで新規・解消・共通を算出する。同じ (file, message) ペアが複数回出現する場合のエッジケースもカバー。

テストがこの挙動をロックしている:

public function test_compare_ignores_line_shifts(): void
{
    // 同じメッセージ、行番号違い → 差分なし
    $diff = (new Baseline($parser))->compare($current['errors'], $base['errors']);
    $this->assertSame([], $diff['new']);
    $this->assertSame([], $diff['resolved']);
    $this->assertCount(1, $diff['shared']);
}

--fail-on-new ゲート

CI で本当に欲しいのは「この PR で新しい指摘が増えたらマージさせない。既存の指摘ではブロックしない」というゲート。--fail-on-new フラグがそれを提供する。

GitHub Actions フォーマッタ

public function format(array $errors): string
{
    $out = '';
    foreach ($errors as $e) {
        $command = match ($e['level']) {
            Parser::LEVEL_ERROR   => 'error',
            Parser::LEVEL_WARNING => 'warning',
            default               => 'notice',
        };
        $file = $this->escapeProperty($e['file']);
        $msg  = $this->escapeData($e['message']);
        $line = $e['line'] > 0 ? ",line={$e['line']}" : '';
        $out .= sprintf("::%s file=%s%s::%s\n", $command, $file, $line, $msg);
    }
    return $out;
}

30 秒で試す

git clone https://github.com/sen-ltd/phpstan-report.git
cd phpstan-report
docker build -t phpstan-report .

# コミット済みフィクスチャで実行
docker run --rm -v $(pwd)/tests/fixtures:/work phpstan-report /work/mixed.json

# Markdown テーブル
docker run --rm -v $(pwd)/tests/fixtures:/work phpstan-report /work/mixed.json --format markdown

# ベースライン差分 + リグレッションゲート
docker run --rm -v $(pwd)/tests/fixtures:/work phpstan-report \
    /work/mixed.json --baseline /work/baseline.json --fail-on-new

自分のプロジェクトに対して:

./vendor/bin/phpstan analyse src --error-format=json | phpstan-report -

56 テスト、ソース 1,000 行以下。MIT ライセンス。

トレードオフ


SEN 合同会社100 超のポートフォリオシリーズ エントリ #175。