PHPStan の JSON 出力はノイズだらけ。ベースライン差分付きフォーマッタを書いた
PHPStan の JSON 出力はノイズだらけ。ベースライン差分付きフォーマッタを書いた
PHPStan 自体は素晴らしい。しかし人間向け出力は CI ログから流れて構造が失われ、JSON 出力はストレージ形式のような形をしており、ベースラインは
.neonファイルで GitHub の差分ビューアが上手くレンダリングできない。この記事は、その 3 つの問題を解決する 500 行の PHP CLI について。
📦 GitHub: https://github.com/sen-ltd/phpstan-report

課題
実プロジェクトの 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 つのコンポーネント:
Parser── PHPStan の JSON をフラットなerrors[]リストに正規化。各エントリにfile,line,message,identifier,ignorable, そして追加のlevelフィールド(error,warning,info)。Baseline── 前回の実行結果と(file, message)で比較。新規エラー、解消済みエラー、共通エラー。- 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 ライセンス。
トレードオフ
- SARIF 出力なし。 ロードマップにはあるが、初期版は PR コメント / CI ログワークフローにフォーカス。
- PHPStan は実行しない。 出力をパイプで受ける。PHPStan 自体の進化と独立して動作する。
- 分類器は正規表現リスト。 シンプルで速く、80% のケースをカバー。賢くはなく、今後も賢くする予定はない。
- 設定ファイルなし。 v1 では意図的にゼロコンフィグ。
SEN 合同会社の 100 超のポートフォリオシリーズ エントリ #175。