PHPer向けのpre-commitで静的解析ライフ

## 追記と補足
Twitterにて諸先輩方からご指摘を頂いたのでそのやりとりを載せます。（許可もいただいてます）
要約すると、pre-commitで静的解析するよりもCIのタイミングで静的解析した方がいろいろいいことがあるよってところです。
そちらの意見を推奨なので、この記事は **「こういうこともできるよ。でももっといい運用があるから特別推奨はしないよ。」** 程度の気持ちで読んでください。

ただ、せっかく慣れないシェルスクリプト書いたしまぁ使えないわけではないので元のスクリプトの部分も残してはおきます。

https://twitter.com/tadsan/status/1386825390967062532

https://twitter.com/d_hori_web/status/1386826119605747717

https://twitter.com/tadsan/status/1386842464430874626

https://twitter.com/tadsan/status/1386828286647422978

補足終わり。

## PHPer向けのpre-commitスクリプト
Gitに上げる前に静的解析する為に作りました。
一応[Github](https://github.com/nsym-m/pre-commit-for-php)にも上げました。

> ※Laravel想定なので、他のフレームワークの場合はphpstanの設定周りで若干読み替えてください。

## pre-commitとは
設定した状態で`git commit`を実行するとコミットの前にこのスクリプトが走り、内容に問題があるとコミットが中止されます。(雑)



## 構成
- phpstan(larastan)
- php -l(phpstan成功時に実行)
    - phpstanがどの程度検出できるか正直わからないので念の為
- php-cs-fixer

特定ディレクトリ以下などを対象に実行してしまうと改修範囲が膨大になり導入コストが上がってしまう為、
既存プロジェクトなどへの導入コスト低減の為**コミット対象のファイルにのみ実行**させている。

## pre-commitファイル
シェルは慣れてないので書き方はよくないかもです。
もっと良い書き方があればコメントいただけると幸いです。
特にエラー判定のあたりは正直「本当にこれでいいのか？」と思いながら書いてます...。

あと後半は概ねデフォルトのまんま。
↓がpre-commitのファイルです。

```sh
#!/bin/sh

if git rev-parse --verify HEAD >/dev/null 2>&1
then
	against=HEAD
else
	# Initial commit: diff against an empty tree object
	against=`git hash-object -t tree /dev/null`
fi

phpfiles=`git diff --name-only --diff-filter=d $against | grep \.php`
if [ "$phpfiles" != "" ]; then

    echo '####\033[32m check phpstan \033[m####'
    echo '####\033[32m check php-cs-fixer \033[m####'
    echo '####\033[32m check php -l \033[m####'
    echo

    # ディレクトリ定義
    ROOT_DIR=`git rev-parse --show-toplevel`

    # エラー定義
    errorStan=false
    errorCs=false
    errorL=false
    for file in `git diff-index --name-status $against -- | grep -E '^[AUM].*\.php$'| cut -c3-`
    do
        # phpstan実行 全ファイルにチェックすると既存ファイルにエラーがある時コミットできないので、コミット対象のファイルのみチェックする
        phpstanCheck=`./vendor/bin/phpstan analyze --memory-limit=2G $ROOT_DIR/$file`
        # エラーがなければ'No errors'という文字列があるので、それがない場合にエラーと判断
        echo $phpstanCheck | grep 'No errors' > /dev/null 2>&1
        # $?には直前の実行結果が入る
        if [ $? != 0 ]; then
            echo "####\033[31m　phpstan failed!!!! \033[m####$phpstanCheck\n"
            errorStan=true
        else
            # php -lによるシンタックスチェック実行 app/配下以外だとphpstanが実行されない気がする？為入れてる
            syntaxCheck=`php -l $file`
            # エラーがなければ'No syntax errors'という文字列があるので、それがない場合にエラーと判断
            echo $syntaxCheck | grep 'No syntax errors' > /dev/null 2>&1
            if [ $? != 0 ]; then
                # シンタックスエラーのあった場合はエラー内容を出力
                echo "####\033[31m php -l failed!!!! \033[m####$syntaxCheck\n"
                errorL=true
            fi
        fi

        # php-cs-fixer実行 --dry-run で修正はせずに引っ掛かったらコミットさせずに修正を促す
        # --path-modeをintersectionにすることで、Finderが無視されないようにする
        .tools/php-cs-fixer/vendor/bin/php-cs-fixer fix --path-mode=intersection --dry-run $ROOT_DIR/$file > /dev/null 2>&1
        if [ $? != 0 ]; then
            echo "####\033[31m php-cs-fixer failed!!!! \033[m####\n $ROOT_DIR/$file\n"
            errorCs=true
        fi
    done

    # "phpstan", "php-cs-fixer", "php -l"のどれかに引っ掛かっていたらコミットを中断
    if "${errorStan}"; then
        echo "####\033[31m　Commit fail\033[m please fix \033[31mphpstan errors\033[m ####"
    fi
    if "${errorL}"; then
        echo "####\033[31m　Commit fail\033[m please \033[31msyntax check\033[m ####"
    fi
    if "${errorCs}"; then
        echo "####\033[31m　Commit fail\033[m please run \033[31m\"tools/php-cs-fixer/vendor/bin/php-cs-fixer fix\"\033[m command ####"
    fi
    if [ $errorStan -o $errorL -o $errorCs ]; then
        exit 1
    fi

    echo '####\033[32m phpstan, php -l, php-cs-fixer all completed!! \033[m####'

fi

# この下はほぼ`git init`で生成されたデフォルトのまま

# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --bool hooks.allownonascii)

# Redirect output to stderr.
exec 1>&2

# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
	# Note that the use of brackets around a tr range is ok here, (it's
	# even required, for portability to Solaris 10's /usr/bin/tr), since
	# the square bracket bytes happen to fall in the designated range.
	test $(git diff --cached --name-only --diff-filter=A -z $against |
	  LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then

    echo "####\033[31m ここでエラーの場合はコミットしたファイルに空白行に余分なスペースがある可能性があります。\033[m ####
####\033[31m もしどうしても解決できない場合は'git commit -m \"commit message\" --no-verify'でprecommitを無効化させてください。(非推奨) \033[m ####"

	cat <<\EOF
####
Error: Attempt to add a non-ASCII file name.

This can cause problems if you want to work with people on other platforms.

To be portable it is advisable to rename the file.

If you know what you are doing you can disable this check using:

  git config hooks.allownonascii true
####
EOF
	exit 1
fi

# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

```

## Set up

- macOS想定
- gitは導入済みな想定
- composerも導入済みな想定

### 静的解析に必要なライブラリの導入

- 各プロジェクトに`phpstan(larastan)`と`php-cs-fixer`が導入されている前提なので、これらの導入が必要。
- ここではLaravel想定なので、`phpstan`ではなく`larastan`を導入するが、Laravel以外の場合は`phpstan`を導入してください。下に導入方法も書いてますが、古い可能性もあるので以下リンクの公式のインストールガイドを参照してください。
    - [nunomaduro/larastan](https://github.com/nunomaduro/larastan)
    - [phpstan/phpstan](https://phpstan.org/user-guide/getting-started)
    - [friendsofphp/php-cs-fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer)

#### phpstan(larastan)の導入

```sh
$ cd path/to/your/project
$ composer require --dev nunomaduro/larastan
# Laravel以外の場合
# $ composer require --dev phpstan/phpstan
$ vim phpstan.neon # 好きなエディタでプロジェクトrootに以下を作成
```
↓`phpstan.neon`です。ほぼ公式のまま。

```yml
# phpstan.neon(for larastan)
includes:
    - ./vendor/nunomaduro/larastan/extension.neon

parameters:

    paths:
        - app

    # The level 8 is the highest level
    level: 5

    ignoreErrors:
        - '#Unsafe usage of new static#'

    excludePaths:

    checkMissingIterableValueType: false

```

#### php-cs-fixerの導入

```sh
$ cd path/to/your/project
$ mkdir -p tools/php-cs-fixer
$ composer require --working-dir=tools/php-cs-fixer friendsofphp/php-cs-fixer
$ echo .php_cs.cache >> .gitignore # .gitignoreに.php_cs.cacheを追記
$ vim .php_cs.dist # 好きなエディタでプロジェクトrootに以下を作成
```
↓`.php_cs.dist`です。
こちらはsuinさんの[ソースコードの“赤ペン先生”PHP-CS-Fixerのインストールと設定](https://qiita.com/suin/items/4242aec018d086312fe7)をありがたく流用させていただいています。感謝。

<details><summary>長いので折りたたんでます。</summary><div>


```php
<?php

use PhpCsFixer\Config;
use PhpCsFixer\Finder;

$rules = [
    'array_syntax' => ['syntax' => 'short'],
    'binary_operator_spaces' => [
        'default' => 'single_space',
        'operators' => ['=>' => null]
    ],
    'blank_line_after_namespace' => true,
    'blank_line_after_opening_tag' => true,
    'blank_line_before_statement' => [
        'statements' => ['return']
    ],
    'braces' => true,
    'cast_spaces' => true,
    'class_attributes_separation' => [
        'elements' => ['method']
    ],
    'class_definition' => true,
    'concat_space' => [
        'spacing' => 'none'
    ],
    'declare_equal_normalize' => true,
    'elseif' => true,
    'encoding' => true,
    'full_opening_tag' => true,
    'fully_qualified_strict_types' => true, // added by Shift
    'function_declaration' => true,
    'function_typehint_space' => true,
    'heredoc_to_nowdoc' => true,
    'include' => true,
    'increment_style' => ['style' => 'post'],
    'indentation_type' => true,
    'linebreak_after_opening_tag' => true,
    'line_ending' => true,
    'lowercase_cast' => true,
    'lowercase_constants' => true,
    'lowercase_keywords' => true,
    'lowercase_static_reference' => true, // added from Symfony
    'magic_method_casing' => true, // added from Symfony
    'magic_constant_casing' => true,
    'method_argument_space' => true,
    'native_function_casing' => true,
    'no_alias_functions' => true,
    'no_extra_blank_lines' => [
        'tokens' => [
            'extra',
            'throw',
            'use',
            'use_trait',
        ]
    ],
    'no_blank_lines_after_class_opening' => true,
    'no_blank_lines_after_phpdoc' => true,
    'no_closing_tag' => true,
    'no_empty_phpdoc' => true,
    'no_empty_statement' => true,
    'no_leading_import_slash' => true,
    'no_leading_namespace_whitespace' => true,
    'no_mixed_echo_print' => [
        'use' => 'echo'
    ],
    'no_multiline_whitespace_around_double_arrow' => true,
    'multiline_whitespace_before_semicolons' => [
        'strategy' => 'no_multi_line'
    ],
    'no_short_bool_cast' => true,
    'no_singleline_whitespace_before_semicolons' => true,
    'no_spaces_after_function_name' => true,
    'no_spaces_around_offset' => true,
    'no_spaces_inside_parenthesis' => true,
    'no_trailing_comma_in_list_call' => true,
    'no_trailing_comma_in_singleline_array' => true,
    'no_trailing_whitespace' => true,
    'no_trailing_whitespace_in_comment' => true,
    'no_unneeded_control_parentheses' => true,
    'no_unreachable_default_argument_value' => true,
    'no_useless_return' => true,
    'no_whitespace_before_comma_in_array' => true,
    'no_whitespace_in_blank_line' => true,
    'normalize_index_brace' => true,
    'not_operator_with_successor_space' => true,
    'object_operator_without_whitespace' => true,
    'ordered_imports' => ['sortAlgorithm' => 'alpha'],
    'phpdoc_indent' => true,
    'phpdoc_inline_tag' => true,
    'phpdoc_no_access' => true,
    'phpdoc_no_package' => true,
    'phpdoc_no_useless_inheritdoc' => true,
    'phpdoc_scalar' => true,
    'phpdoc_single_line_var_spacing' => true,
    'phpdoc_summary' => true,
    'phpdoc_to_comment' => true,
    'phpdoc_trim' => true,
    'phpdoc_types' => true,
    'phpdoc_var_without_name' => true,
    'psr4' => true,
    'self_accessor' => true,
    'short_scalar_cast' => true,
    'simplified_null_return' => false, // disabled by Shift
    'single_blank_line_at_eof' => true,
    'single_blank_line_before_namespace' => true,
    'single_class_element_per_statement' => true,
    'single_import_per_statement' => true,
    'single_line_after_imports' => true,
    'single_line_comment_style' => [
        'comment_types' => ['hash']
    ],
    'single_quote' => true,
    'space_after_semicolon' => true,
    'standardize_not_equals' => true,
    'switch_case_semicolon_to_colon' => true,
    'switch_case_space' => true,
    'ternary_operator_spaces' => true,
    'trailing_comma_in_multiline_array' => true,
    'trim_array_spaces' => true,
    'unary_operator_spaces' => true,
    'visibility_required' => [
        'elements' => ['method', 'property']
    ],
    'whitespace_after_comma_in_array' => true,
];


$finder = Finder::create()
    ->in([
        __DIR__ . '/app',
        __DIR__ . '/config',
        __DIR__ . '/database',
        __DIR__ . '/resources',
        __DIR__ . '/routes',
        __DIR__ . '/tests',
    ])
    ->name('*.php')
    ->notName('*.blade.php')
    ->ignoreDotFiles(true)
    ->ignoreVCS(true);

return Config::create()
    ->setFinder($finder)
    ->setRules($rules)
    ->setRiskyAllowed(true)
    ->setUsingCache(true);

```
</div></details>
<br>

> Note: ※これ以下のgitの設定については詳しくは[公式ドキュメント](https://git-scm.com/docs/git-config/en)を参照してください。

### 端末の全リポジトリに共通で設定する場合

```sh
{pre-commitファイルをダウンロード}
$ mkdir -p ~/.config/git/hooks
$ mv ~/Downloads/pre-commit ~/.config/git/hooks/pre-commit
$ chmod +x ~/.config/git/hooks/pre-commit
$ git config --global core.hooksPath '~/.config/git/hooks'
```

### 既にclone済みの特定のリポジトリにのみ設定する場合

```sh
{pre-commitファイルをダウンロード}
$ cd path/to/your/project
$ mkdir .git/hooks
$ mv ~/Downloads/pre-commit .git/hooks/pre-commit
$ chmod +x .git/hooks/pre-commit
```

### 既存のリポジトリには設定したくないが、これから新規にcloneするリポジトリは全て反映させたい場合

```sh
{pre-commitファイルをダウンロード}
$ mkdir -p ~/.git_template/hooks
$ mv ~/Downloads/pre-commit ~/.git_template/hooks/pre-commit
$ chmod +x ~/.git_template/hooks/pre-commit
$ git config --global init.templatedir '~/.git_template/hooks'
```

## 実行

こんな風になります。
![pre-commit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494315/9da97ba6-3e24-517d-eff5-214a6b694277.png)

ちなみに既知の不具合として、Sourcetreeで行うと色が微妙な感じになりますが、対応する予定はありません。
![スクリーンショット 2021-04-26 22.45.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494315/26d1db52-04e7-c6d0-a33a-505c357cd767.png)



## 余談
- 本当はこれはエディタレベルでやってしまいたい。
- VS Codeの`intelephense`というPHPの拡張機能が優秀なのでみんな入れましょうね。
- 余裕ができたらGitLabのCI環境も整備したい。


### 素敵な静的解析ライフを！