ログ日記

作業ログと日記とメモ

PHPの静的解析 Phan/Psalm/PHPStan の違い

エラーチェックのためにPHPで静的解析ツールをする場合、Phan, Psalm, PHPStan を使えば良いということは検索ですぐ出てくるのだが、どれを使えばいいのか。
それぞれのツールで検知できるものが微妙に異なっているので、全部使うのが安全ではある。

それでも動作の違いや思想の違いを知っておきたいので調べる。

github.com
github.com
github.com

自分がエラーを検知してほしいまたは正しく型チェックしてほしいと思うコードを用意してそれぞれチェックしてみるのが手っ取り早い。
思いつくコードを調べてみた。
設定は基本的に初期のまま。PhanはREADMEにある設定、Psalmはレベル1、PHPStanはレベル8にした。(PsalmとPHPStanで検知レベルの向きが逆である)

<?php
/**
 * @param array<int,string> $arr
 */
function test1(array $arr, ?int $index): ?string
{
    $ret = null;
    if (isset($arr[$index]))
        $ret = $arr[$index];
    return $ret;
}
Phan Psalm PHPStan
PhanTypeMismatchDimFetchNullable エラーなし エラーなし

Phan は配列のインデックスに nullable な値を指定するとエラーになる。

<?php
/**
 * @param array<int,string> $arr
 */
function test1(array $arr): ?string
{
    if (isset($arr[null]))
        return $arr[null];
    return null;
}
Phan Psalm PHPStan
PhanTypeMismatchDimFetchNullable NullArrayOffset エラーなし

こちらはPsalmでもエラー。
PHPの文法的にはOKだが、null は自動的に空文字 '' に変換されるので、PHPStanも正しくはない。



<?php
/** @return mixed */
function test2()
{
    $a = $_GET['a'];
    return $a;
}
Phan Psalm PHPStan
エラーなし MixedAssignment エラーなし

Psalm は mixed の扱いに厳しい。

<?php
function test3(): int
{
    throw new Exception('err');
    return 0;
}
Phan Psalm PHPStan
PhanPluginUnreachableCode エラーなし Unreachable statement

Psalm は未到達コードをチェックしないようである。
https://github.com/vimeo/psalm/issues/403#issuecomment-354441656
オプションを付ければチェックされるようだが、デフォルトではreturnやexitの後のコードはチェックされないようだ。

<?php
/**
 * @template T of DateTimeInterface
 * @param T $d
 * @return T
 */
function test4($d)
{
    $d->format('d');
    return $d;
}
Phan Psalm PHPStan
PhanUndeclaredClassMethod エラーなし エラーなし

Phanはジェネリクスに of が使えないようだ。

<?php
function test4(): void
{
    var_dump(1);
}
Phan Psalm PHPStan
エラーなし ForbiddenCode エラーなし

Psalm は var_dump がエラーする。
エラーを見る限り、危険な関数の使用自体がチェックされるようである。

<?php
/**
 * @template T of DateTime
 * @template U of DateTime
 * @param iterable<T,iterable<string,U>> $dss
 * @param callable(string):int $f
 * @return ?int
 */
function test5($dss, $f)
{
    foreach ($dss as $k => $ds){
        foreach ($ds as $s => $d){
            $diff = $k->diff($d);
            if ($diff->format('%d') == 10)
                return $f($s);
        }
    }
    return null;
}
Phan Psalm PHPStan
PhanUndeclaredClassMethod エラーなし エラーなし

Phan は of が使えないのでエラー。PsalmもPHPStan も入れ子ジェネリクスとコールバック関数の型を理解できる。試しに戻り値の型を ?string にするとエラーになる。

<?php
function test6($a)
{
    echo 'a';
}
Phan Psalm PHPStan
エラーなし MissingParamType, MissingReturnType parameter $a with no typehint specified, no return typehint specified

PHPStanの方がエラーが詳しいような書き方になってしまったがそうではなく、Psalm のようなエラーの型がないだけである。Psalmも詳しいメッセージは出力されている。
Psalm と PHPStan は型が必須のようだ。

<?php
/**
 * @template T
 * @param class-string<T> $c
 * @return T
 */
function test7($c)
{
    return new $c;
}
Phan Psalm PHPStan
エラーなし MixedMethodCall エラーなし

Psalm は mixed に厳しい。コンストラクタの引数が分からないのでnew できるかは不明というのは正しい。

<?php
interface Foo {
    public function bar(string $bar, int $baz): int;
}
class FooImpl implements Foo {
    public function bar(string $_, int $i): int
    {
        return $i++;
    }
}
Phan Psalm PHPStan
PhanParamNameIndicatingUnused ParamNameMismatch エラーなし

PhanとPsalmはパラメーター名が異なればエラー。
今までは単なるコード整形だったけれども、PHP8 で named parameters が来ると意味が変わってくる。

<?php
abstract class P
{
    abstract protected function a(DateTimeImmutable $d): DateTimeInterface;
}
class C extends P
{
    protected function a(DateTimeInterface $d): DateTimeImmutable
    {
        return new DateTimeImmutable($d->format('y-m-d'));
    }
}
Phan Psalm PHPStan
PhanParamSignatureRealMismatchParamType, PhanParamSignatureRealMismatchReturnType MethodSignatureMismatch エラーなし

PHPStanだけPHPの文法に合うようになっている。 (※コメントも参照)
PhanとPsalmはわざとエラーにしている?でもエラーメッセージを見ると共変性と反変性をあえて禁止にしているようには見えない。

<?php
function test8(int $i): bool
{
    switch(true){
    case $i <= 10:
        return true;
        break;
    case $i > 10 && $i <= 20:
        return false;
        break;
    default:
        return true;
        break;
    }
    return true;
}
Phan Psalm PHPStan
PhanPluginUnreachableCode エラーなし Unreachable statement, Comparison operation ">" between int<11, max> and 10 is always true.

Psalm はデッドコードをチェックしない。
PHPStanは、条件10がかぶっていることを見つけてエラーにする。

<?php
function test9(string $s): string
{
    return sprintf('foo%s, %s', $s);
}
Phan Psalm PHPStan
PhanPluginPrintfNonexistentArgument エラーなし Call to sprintf contains 2 placeholders, 1 value given.

PhanとPHPStan はsprintfのフォーマットをチェックする。

<?php
function test9(string $s): string
{
    return sprintf('foo%s, %s', $s, 1);
}
Phan Psalm PHPStan
PhanPluginPrintfIncompatibleArgumentType エラーなし エラーなし

Phanは更に型までチェックする。
PHPStan も実は型をチェックしていて、1をnew DateTimeに変えるとエラーになる。bool|float|int|string|null を受け付けるようだ。



さて、大まかな違いが見えてきた。
Phanは全体的にバランス良くチェックしようとしているが、ジェネリクスの機能が弱い。関数のシグネチャに型がなくてもスルーする。レガシーコードに利用するのに向いていそうだ。
Psalmはmixedに厳しく、全ての型を指定しろという強い意志を感じる。新規プロジェクトに向いている。
PHPStanはPHPが許す文法には寛容に見えるし、mixedをスルーして共変性と反変性を正しく実装しているのは元々のPHPの動作を尊重しているからかもしれない。



※ この記事はQiitaの PHP その2 Advent Calendar 2020 の5日目の記事、今空いてたから登録した。


※ 12/7 追記
コメントをもらったので再チェック。
psalm の使い方が分かってないのかも…。

% ./vendor/bin/psalm --clear-cache
Cache directory deleted
% ./vendor/bin/psalm --version
Psalm 4.2.1@ea9cb72143b77e7520c52fa37290bd8d8bc88fd9
%
% ./vendor/bin/psalm
Scanning files...
Analyzing files...

E

ERROR: MethodSignatureMismatch - src/functions.php:9:5 - Method C::a with return type 'DateTimeImmutable' is different to return type 'DateTimeInterface' of inherited method P::a (see https://psalm.dev/042)
    public function a(DateTimeInterface $d): DateTimeImmutable


ERROR: MethodSignatureMismatch - src/functions.php:9:41 - Argument 1 of C::a has wrong type 'DateTimeInterface', expecting 'DateTimeImmutable' as defined by P::a (see https://psalm.dev/042)
    public function a(DateTimeInterface $d): DateTimeImmutable


------------------------------
2 errors found
------------------------------

Checks took 0.44 seconds and used 57.117MB of memory
Psalm was able to infer types for 100% of the codebase
%
% cat src/functions.php
<?php

abstract class P
{
    abstract public function a(DateTimeImmutable $d): DateTimeInterface;
}
class C extends P
{
    public function a(DateTimeInterface $d): DateTimeImmutable
    {
        return new DateTimeImmutable($d->format('y-m-d'));
    }
}

https://psalm.dev/r/52f57aeabe と結果が異なる。