emahiro/b.log

Drastically Repeat Yourself !!!!

Go で作る自作 Linter 開発入門

Overview

Go の静的解析、というか x/tools/go/analysis package を利用して簡単にコードを検査する自作 Linter の実装に入門します。

今回使ったサンプル実装は以下に置いてあります。

github.com

準備

以下のツールを使います。

  • GoAst Viewer
  • 以下の Go のパッケージ
    • golang.org/x/tools/go/analysis
    • golang.org/x/tools/go/analysis/passes/inspect
    • golang.org/x/tools/go/analysis/singlechecker
    • golang.org/x/tools/go/ast/inspector

自作 Linter を作る手順

GoAst Viewer で Ast の構造を視覚的に確認する

僕もそうですが、初心者の一番のハードルって Ast の構造を頭の中でトレースすることができないことだと思います。いまだに慣れません。

そこでこの GoAst Viewer って言うツールはとても重宝してて、検査したい Bad コードに似たサンプルコード*(コンパイル通らなくても良いを載せて、自作検査ツールを作る前にまずどう言う構造をしてるのかをチェックします。

※ プロダクションコードなんかの検査をしたいケースはそのまま form に商用利用してるコードを叩き込むのも不安かなと思うので。機密情報ですし。

追記

x/tools/cmd/gotype

pkg.go.dev

gotype -ast $File を使うと GoAst Viewer の Dump と同じ結果が得られるので、商用利用してるコードの静的解析を行いたい場合はこれを使うと良さそうです。
Dump を読むのはちょっと骨が折れますが。

go get -u golang.org/x/tools/cmd/gotype
gotype -ast ./testdata/src/a/a.go

singlechecker を使って検出する

go/analysis を使ってコードの検査をするには singlechecker package を使うのが簡単です。

analysis.Analyzer を定義してそれを singlechecker に放り込むだけでファイル及びファイルの中を ast の Node に従って検査をしてくれます。

var a = &analysis.Analyzer{
    Name:     "samplechecher",
    Doc:      "sample code checker",
    Run:      run,
    Requires: []*analysis.Analyzer{inspect.Analyzer},
}

func main() {
    singlechecker.Main(a) // run の中が実行されます。
}

func run(pass *analysis.Pass) (interface{}, error) {
    i := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
    filter := []ast.Node{}
    i.Preorder(filter, func(n ast.Node) {})
    return nil, nil
}

ref: https://github.com/emahiro/il/pull/29/commits/1eada5667a829f5f2761faec21d7140ea4fefdab

あとはひたすら ast の Node を掘っていってチェックしたい文字列に対して pass.Report で出力をするだけです。

nodeFilter で階層を指定する

これちょっとハマったんですが、検査対象の ast.Node をフィルタすることができます。例えば GoAst Viewer で確認すると、階層としては FuncDecl > CallExpr と言う順序になって CallExpr の方が 階層が深いのでここで指定した ast.Node 以下のみを検査対象とすることができますが、逆に言うとフィルタリングしてしまった Node より上層の Node については検査できません。

// 以下は FuncDecl 以下の Node を検査対象にできる。
filter := []ast.Node{
        (*ast.FuncDecl)(nil),
}

// 以下は CallExpr 以下の Node を検査対象にできるが、FUncDecl は対象にならない(っぽい)
filter := []ast.Node{
        (*ast.CallExpr)(nil),
}

Tips

SuggestedFixes がめちゃくちゃ便利なので絶対に使う

同僚の daisuzu さんのブログがめっちゃ詳しいですが、コードの修正がコードから簡単に行えます。

daisuzu.hatenablog.com

使い方

サンプルコードは以下です。これは Smaple と言う文字列を Example に変えたいと言う場合に SuggestedFixied を使った例です。

pass.Report(
    analysis.Diagnostic{
        Pos:     ident.Pos(),
        End:     ident.End(),
        Message: "change Sample to Example",
        SuggestedFixes: []analysis.SuggestedFix{
            {
                Message: "Sample -> Example",
                TextEdits: []analysis.TextEdit{
                    {
                        Pos:     ident.Pos(),
                        End:     ident.End(),
                        NewText: []byte("Example"),
                    },
                },
            },
        },
    },
)

このように書いておいてあとは go run . -fix $File のように自作ツールに -fix オプションを設定して実行することで、自作ツールの検査で引っかかったところを直したい文字列に自動で修正してくれます。

ハマったところ

Pos/End の位置

というかよく使ってて間違うんですが Position を取り違えると意図せず丸ごと変換してしまいます。サンプル PR だと以下のコミットの前後です。

このコミットを積むまでは例えば func Sample() error という関数名を func Example() error と変更したくても、position が意図とは違った位置で書き換えを行なってしまい Sample と含まれる全ての行そのものを Example に変換してしまいます。このため、より範囲を限定した ast.Ident を使ってる箇所を書き換えることで意図する挙動にしました。

こればっかりは動かしてみながら調整する他ないかなと思いますが、どの範囲(Ast Viewerで確認できる)を今操作してるのかをみながら実装するのがいいと思います。

検査の出力結果をテストする

検出されるべき文字列をテストすることができます。

自作ツールの動作(警告文など)をテストするツールとしてanalysis パッケージは analysistest を提供しており、その中の https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest#Run 関数において自作 Linter で検出されるコードが書かれてる行に以下のように want 文を書くと example テストのように検出結果のテストができます。

// ./testdata/src/a/a.go
func Sample() error { // want `change Sample to Example`
    return nil
}

SuggestedFixes を使ってる場合は https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest#RunWithSuggestedFixes が用意されており、これを使ってテストする場合、検出 & 修正後の結果になっている golden ファイルを要求されます。(これで出力結果だけでなく、文字列修正した場合の結果もテストできます。)

ちなみにテストは go.mod と同階層に testdata ディレクトリを用意してその配下で通常のパッケージ構成のようにテスト用の Go のファイルを用意することで analysistest から簡単にテストを実行できます。

// ディレクトリ構成
go.mod
testdata
    src
        a
            a.go

テストを実装するときは以下のように実装します。

testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, a, "a")

※ このとき a package 内に Fixed した後の結果を出力してある golden ファイルがあることが必要です。じゃないと test を回したときに以下のようなエラーが発生します。

analysistest.go:171: error reading $PATH/src/github.com/emahiro/il/go_sandbox/codeanlyzersample/testdata/src/a/a.go.golden: open $PATH/src/github.com/emahiro/il/go_sandbox/codeanlyzersample/testdata/src/a/a.go.golden: no such file or directory

ハマったところ

大きな話でもないんですが、記号(ex. () など)を pass.Report の中で使っている場合、警告メッセージを出す want のないのバッククオテーションのなかで目地的に \(\) のようにエスケープすることが求められます。これをしないと警告文が違うと判定されてテストが延々とおりません。

ReviewDog 🐶 と組み合わせてみる

ReviewDog には検査で引っかかったところを GUI 上で Commit を積んで修正できる suggester の機能があります。これをつかうと自作チェッカーで解析して検査結果を出力したところを手元でコミットを積むことなく GitHub の PR の GUI 上から検査できて良いので自作チェッカーと組み合わせて使いたいとします。

ハマったところ

go run . ./testdata/src/a/a.go
$PATH/src/github.com/emahiro/il/go_sandbox/codeanlyzersample/testdata/src/a/a.go:10:1: change Sample to Example
$PATH/src/github.com/emahiro/il/go_sandbox/codeanlyzersample/testdata/src/a/a.go:14:1: change SampleWithContext to ExampleWithContext
exit status 3

上記の終了時のステータスコードに着目すると exit status が 3で、正常終了してません。

静的解析結果で引っかかったところがあるとツール内(checker の実装内)で正常終了で終わってくれず、例えばツールで引っかかった結果に対して Suggestion Fix をかけたいケースなどでは正常にその先に Suggestion Fix に結果が伝播しない、と言うことがありました。

以下のコードをみるとわかります。

上記にも記載しましたが、GitHub Actions で ReviewDog の Suggester を動かしたい場合は検査結果と変更の提案(diff形式)を検出した後 checker の実行は正常終了として扱い、検出された diff 形式を Reviewdog の Suggester まで渡さないといけません。つまり exit code 3 では困ります。

そこで actions の定義 yaml の中で以下のように Run を定義することで解決しました。

- name: Run reviewdog
        env:
          REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.YourGHSecretToken }}
        run: go run checker -fix $FilePath | exit 0 // パイプでつないで checker の結果の exit code 3 を正常終了(= exit code 0) にねじ曲げる。
- uses: reviewdog/action-suggester@v1
        with:
          github_token: ${{ secrets.YoutGHSecretToken }}
          level: "$Severity"
          fail_on_error: false // true にすると CI の検査結果が Fail になります。

まとめ

GoAst Viewer と singlechecker を使うと小難しいことをとりあえず抜きにして、とりあえず簡単に静的解析に入門できます。構造さえわかってしまえは Linter 作成はほぼ筋力で解決できるのでやろうと思えば誰でもできます。個人的には SuggestedFixes が本当に便利なのと、ReviewDog と組み合わせるとレビューの生産性はいくらでも向上すると思っています。

現職でも不具合や障害を起こした実装を繰り返さないようにいくつか有志で自作ツールが実装されて実際に CI の中で動いています。プロダクト固有の実装ルールなどはいくらでもあると思いますし、Go を書く上での一般的な Linter(golangci-linter や staticcheck) でカバーできないものがある場合に非常に有用なのでぜひ実装してみてください。

僕も慣れるまでは何個か時間見つけて作ってみます。ちなみにテンプレはこのコミットを作ったので参考にしてみてください → https://github.com/emahiro/il/pull/29/commits/1eada5667a829f5f2761faec21d7140ea4fefdab

See Also

  • https://github.com/yuroyoro/goast-viewer
  • 今回のサンプルでは使用してませんが、ast の構造上各 Node ごとにより深ぼって検査したい (ex. ast.BlockStmt から CallExpr を取り出して別の検査を走らせたい)場合には ast.Inspect を使うと便利です。具体的には以下のように CallExpr の中に入って検査をすることができます。全部 main で処理しちゃうと複雑なので関数に分けたいケースなどに使います。
func InspectMore(b *ast.BlockStmt) {
    ast.Inspect(ident, func(n ast.Node) bool {
            call, ok := n.(*ast.CallExpr) // BlockStmt -> CallExpr の中に入る.
            if !ok {
                    return true
            }
            // Other lint operation.
            return true // true にすると検査を次に進める.
    })
}

ref: https://pkg.go.dev/go/ast#Inspect