Phanのカスタムプラグイン実装してみた

## はじめに
プロジェクト固有のコーディングルールを追加したくなったときに、[Phan](https://github.com/phan/phan)のプラグインが使えないかと格闘してたら割とシンプルに実装できたので、記事にしておきます。

## リファレンス
`Phan`を使ってみたという記事は割とあるのですが、プラグインに関する日本語の記事は多くはありません。

- [PHP: Phan (PHP静的解析ツール) の プラグイン — Algo13 2016.12.17 ドキュメント](http://algo13.net/php/phan/plugin.html)

## とにかく実際に試してみるしかない
本家のWiki（[Writing Plugins for Phan · phan/phan Wiki](https://github.com/phan/phan/wiki/Writing-Plugins-for-Phan)）も見ましたが、実際には`Phan`のソースコードを見ながら、色々試行錯誤してみました。
２つほどプラグインを作ってみたので、紹介します。

## 指定された（グローバル）変数の使用禁止
`plugin_config`->`inhibited_variables`にて指定された名前の変数を使うことを禁止するプラグインです。
`$GLOBALS`などのスーパーグローバル変数などを禁止する目的で作りました。

```php
// .phan/plugins/InhibitedVariablesPlugin.php

use ast\Node;
use Phan\Config;
use Phan\PluginV2;
use Phan\PluginV2\PluginAwarePostAnalysisVisitor;
use Phan\PluginV2\PostAnalyzeNodeCapability;

class InhibitedVariablesPlugin extends PluginV2 implements PostAnalyzeNodeCapability
{

    /**
     * @return string - name of PluginAwarePostAnalysisVisitor subclass
     */
    public static function getPostAnalyzeNodeVisitorClassName() : string
    {
        return InhibitedVariablesVisitor::class;
    }
}

/**
 * When __invoke on this class is called with a node, a method
 * will be dispatched based on the `kind` of the given node.
 *
 * Visitors such as this are useful for defining lots of different
 * checks on a node based on its kind.
 */
class InhibitedVariablesVisitor extends PluginAwarePostAnalysisVisitor
{

    // A plugin's visitors should not override visit() unless they need to.

    /**
     * @param Node $node
     * A node to analyze
     *
     * @return void
     * @override
     */
    public function visitVar(Node $node)
    {
        $inhibitedVariables = Config::getValue('plugin_config')['inhibited_variables'];
        if (in_array($node->children['name'], $inhibitedVariables)) {
            $this->emitPluginIssue(
                $this->code_base,
                $this->context,
                'InhibitedVariables',
                'Do not use $'. $node->children['name']. '.',
                []
            );
        }
    }
}

// Every plugin needs to return an instance of itself at the
// end of the file in which it's defined.
return new InhibitedVariablesPlugin();
```

設定ファイルを以下のようにセットします。

```php
// .phan/config.php
    'plugin_config' => [
        'inhibited_variables' => [
            'GLOBALS'
        ],
```

## 指定された（標準）関数の使用禁止
`plugin_config`->`inhibited_functions`にて指定された名前の関数を使うことを禁止するプラグインです。
`extract`などの（危険な）標準関数などを禁止する目的で作りました。

```php
// .phan/plugins/InhibitedFunctionsPlugin.php

use ast\Node;
use Phan\Config;
use Phan\PluginV2;
use Phan\PluginV2\PluginAwarePostAnalysisVisitor;
use Phan\PluginV2\PostAnalyzeNodeCapability;

class InhibitedFunctionsPlugin extends PluginV2 implements PostAnalyzeNodeCapability
{

    /**
     * @return string - name of PluginAwarePostAnalysisVisitor subclass
     */
    public static function getPostAnalyzeNodeVisitorClassName() : string
    {
        return InhibitedFunctionsVisitor::class;
    }
}

/**
 * When __invoke on this class is called with a node, a method
 * will be dispatched based on the `kind` of the given node.
 *
 * Visitors such as this are useful for defining lots of different
 * checks on a node based on its kind.
 */
class InhibitedFunctionsVisitor extends PluginAwarePostAnalysisVisitor
{

    // A plugin's visitors should not override visit() unless they need to.

    /**
     * @param Node $node
     * A node to analyze
     *
     * @return void
     * @override
     */
    public function visitCall(Node $node)
    {
        $name = $node->children['expr']->children['name'] ?? null;
        if (!is_string($name)) {
            return;
        }
        $inhibitedFunctions = Config::getValue('plugin_config')['inhibited_functions'];
        if (in_array($name, $inhibitedFunctions)) {
            $this->emitPluginIssue(
                $this->code_base,
                $this->context,
                'InhibitedFunctions',
                'Do not use '. $name. '().',
                []
            );
        }
    }
}

// Every plugin needs to return an instance of itself at the
// end of the file in which it's defined.
return new InhibitedFunctionsPlugin();
```

設定ファイルを以下のようにセットします。

```php
// .phan/config.php
    'plugin_config' => [
        'inhibited_functions' => [
            'extract'
        ]
```

## 実行サンプル
とにかく手っ取り早く動かしてみたい人は、リポジトリを作成したので、`clone`して実行してみてください。

[imunew/phan-plugins](https://github.com/imunew/phan-plugins)

```bash
$ composer run phan
> PHAN_DISABLE_XDEBUG_WARN=1 vendor/bin/phan
example/InhibitedFunctions.php:18 InhibitedFunctions Do not use extract().
example/InhibitedVariables.php:13 InhibitedVariables Do not use $GLOBALS.
Script PHAN_DISABLE_XDEBUG_WARN=1 vendor/bin/phan handling the phan event returned with error code 1
```

[extract](http://php.net/manual/ja/function.extract.php)を使用している箇所を教えてくれました。

```php
namespace Example;

/**
 * Class InhibitedFunctions
 * @package Example
 */
class InhibitedFunctions
{
    public function extract()
    {
        $vars = [
            "color" => "blue",
            "size"  => "medium",
            "shape" => "sphere"
        ];
        extract($vars); // ここ

        echo "$color, $size, $shape\n";
    }
}
```

## おわりに
`scrutinizer`などの`SaaS`による静的解析に満足できなくなってきたら、`Phan`のカスタムプラグインはだいぶ使えるなと思いました。
こういったことをレビューで指摘するという運用にすると、徹底しなかったり、定着しなかったりするので、自動テストと同様にビルドエラーにしてしまう運用が良さそうです。
テストのないレガシーコードと戦うエンジニアの力になれば幸いです。

ではでは。