CircleCIで勝手に強くなる静的解析の作り方

こんにちは。Garoonチームの杉山(@oogFranz)です。

以前 #PHPerKaigi 2020 にて、「静的解析の育て方」というタイトルで発表いたしました。この発表ではレガシープロダクトにおいて静的解析が有効であることと、「育てる」という比喩表現で静的解析のルールを強くしていく戦略についてお話ししました。

発表後のAsk the Speakerでは様々な方から講演へのフィードバックをいただき、特に既存のプロダクトに静的解析の導入・運用する大変さやその改善方法に関して議論を深めることができました。議論に参加いただいた皆様大変ありがとうございました。

議論の中でルールを自動的に強くしていく方法のヒントをいただきました。そのヒントを元にCircleCIで勝手に強くなる静的解析が実現ができたので紹介したいと思います。

勝手に育つ静的解析の作り方

通常、レガシープロダクトにおいて静的解析を実行すると大量のエラーが報告されてしまいます。「静的解析の育て方」では、Phanと呼ばれる静的解析ツールをGaroonに適用しましたが、はじめは約4万件ものエラー(Phanではissueと呼ぶ)が発生しました。

この大量のissueを抑えるために、Garoonでははじめsuppress_issue_type という設定項目を利用しました。この設定項目に指定されたルールは静的解析時に無視されるようになります。issueが報告されているルールの一覧を作成し、このsuppress_issue_typeに指定することで、issueが報告されない状況を作り、CIを実行できる状況を作り上げました。その後、各ルールごとに、修正するコストと修正しないリスクを測り、少しづつ修正していくという戦略をとりました。しかし、この方法には次のような問題がありました。

  • 静的解析を強くしたい場合、開発者が明示的にsuppress_issue_typeに記載されているルールを更新する必要がある。
  • 設定がプロダクト全体に適用されるため、新規作成されたファイルでもエラーが無視されてしまう。
  • 多くのルールが無視されるため、CIによってエラーが報告されることがほとんどなくなってしまう。

この問題を解決するために、

  • 静的解析のルールを強められる場合には、自動的に強くする。
  • 既存コードの問題は無視したまま、新規作成されたファイルでは強い静的解析を行う。

仕組みを作りました。次の節からその実現方法を紹介します。

勝手に強くなる静的解析の構成

この仕組みの実現には、PhanのベースラインとCircleCIのキャッシュの機能を利用しました。

ベースライン

PhanをはじめとしてPsalmPHPStanといったPHPの主要な静的解析ツールには、既存のコード上のissueを“ベースライン”として保存し、静的解析時にベースラインに記載されたコード上のissueを無視する機能が搭載されています。 ツールにもよりますが、ベースライン作成のコマンドを実行すると、各PHPファイルごとにどのようなissueがあるかがリスト形式で保存されます。

Phanでは次のような形でベースラインの保存と、ベースラインを用いた静的解析が行えます。

phan --save-baseline ~/phan_baseline.php ./ #ベースラインの保存 
phan --load-baseline ~/phan_baseline.php ./ #ベースラインを利用した解析

ベースラインの結果をレポジトリにコミットし、CIでそのベースラインを利用する、といった使い方も可能です。 ベースラインを利用する時は、保存した時と同じ設定値を使う必要があります。

CircleCIのキャッシュ

CircleCIには、過去のジョブや他のジョブのデータをキャッシュし、再利用する仕組みがあります。save_cacheでキャッシュキーとキャッシュする内容を指定でき、restore_cacheでキャッシュ内容を復元できます。一般的にはnpmcomposerといった依存関係管理ツールがインストールするライブラリなどをキャッシュし、CircleCIのジョブを高速化するために利用されます。
今回はベースラインをこのキャッシュに保存しています。

勝手に強くなる静的解析の実現方法

以下のようなCircleCIのジョブphan-load-baselinephan-save-baselineを作成しました。

  • phan-load-baseline: CircleCIのキャッシュからベースラインを取得し、取得したベースラインを利用してPhanを実行します。
  • phan-save-baseline: Phanでエラーが報告されなかった時に、ベースラインを更新します。

これにより静的解析に通ったときにのみベースラインが更新されるため、発生しなくなったissueがあれば、それ以降チェックされるようになる、 つまり静的解析が勝手に強くなるという算段です。

実際には以下のようなconfig.ymlを記述しました。(説明のため、実際に使われている設定ファイルを簡易にしています。)

...
phan-load-baseline:
  docker:
    - image: docker-registory.cybozu.private/phan:X.Y.Z
  steps:
    - checkout
    - restore_cache:
        name: Restore phan baseline
        keys:
          - v1-phan-baseline-{{ .BRANCH }}- #(1)
          - v1-phan-baseline-develop-
    - run:
        name: Phan
        command: |
          if [[ -f ~/phan_baseline.php ]]; then #(2)
            phan ./ --load-baseline ~/phan_baseline.php
          fi

phan-save-baseline:
  docker:
    - image: docker-registory.cybozu.private/phan:X.Y.Z
  steps:
    - checkout
    - run:
        name: Save Phan baseline
        command: |
          set +e #(3)
          phan ./ --save-baseline ~/phan_baseline.php
          exit 0 #(4)
    - save_cache:
        key: v1-phan-baseline-{{ .BRANCH }}-{{ .Revision }} #(5)
        paths:
          - ~/phan_baseline.php
...

さらにphan-save-baselineジョブの依存関係として、以下のようにphan-load-baselineを指定することで、phan-load-baselineでphanの静的解析のエラーが出なかった場合のみベースラインを更新することができます。

...
- phan-save-baseline:
    requires:
      - phan-load-baseline
...

追加で説明を補足していきます。

(1)はフィーチャーブランチとdevelopブランチ両方からベースラインの復元を試みています。フィーチャーブランチのベースラインが優先されますが、フィーチャーブランチが新しく作られたときには、まだベースラインが保存されていないためdevelopブランチのベースラインが利用されます。その後、フィーチャーブランチ上で静的解析を行い、エラーが報告されなければフィーチャーブランチ用のベースラインを利用するようになります。

(2)はキャッシュの読み込みに失敗した時のために記載しています。キャッシュの復元で特にヒットするものがなかった場合もCircleCIはエラーになりません。これはCircleCIのキャッシュがおもに、ジョブの高速化を目的に利用されているためです。

(3)と(4)はCircleCIのジョブを成功状態にするために追加しています。CircleCIは終了コードが0以外だと、ジョブを失敗したとみなします。Phanは、ベースラインの保存時であっても、エラーが検出されたときに0以外の終了コードを返すため、(3)のset +e と(4)のexit 0をつけてジョブが必ず成功するようにしています。

(5)では、キャッシュキーとしてv1-phan-baseline-{{ .BRANCH }}-{{ .Revision }}と、Revisionの値を利用しています。読み取り時に、Revisionの値を利用しないのであれば、書き込み時にも不要なのでは?と思う方もいらっしゃるかもしれませんが、一度保存されたキャッシュは書き換え不可なので、Revisionの値をつけて、最新の状況を更新していく必要があります。

最後に、Phanのライブラリをアップデートするなどして、報告されるissueが大きく変わった場合はどのように対処するかについても説明します。その場合はキャッシュキー名を変えることで対応できます。v1-から始まるキャッシュキーをv2-から始まるように書き換えれば、新たなベースラインがdevelopブランチに作成されるようになります。

まとめ

今回の仕組みにより、静的解析のルールは自動的に強くなっていくようになりました。これにより静的解析の設定ファイルの管理コストを下げることができました。

また、ベースラインがファイル単位でルールを指定しているため、新しく作られたファイルでは、全てのルールが適用されるようになりました。これにより、新機能の開発時には、もっとも強い静的解析のルールが適用されたコードが書かれるようになりました。レガシープロダクトでは、既存のコードが参考にされ、新たなコードが書かれることが多くありますが、この既存のコードに問題があった場合、問題のあるコードがどんどん増えていってしまいます。今回の仕組みによって、そのような心配がなくなったのは大きなメリットでした。

新規のプロダクトでは静的解析ツールを導入することが一般的になってきていますが、既存のプロダクトに静的解析を導入・運用するためには、この記事で紹介したような課題を克服していく必要があります。この記事が少しでもそういった課題を持っている方の助けになれば幸いです。