goplsと静的解析を活用して変更の影響範囲を調べたい

1000パッケージ弱あるような巨大なリポジトリだと、関数1つの修正でどこまで影響があるのかを調べるのが結構大変*1だったりする。

Vimプラグインを作ったり、goplsを魔改造してみたりしてみたものの、使う人や環境を選ぶし、実行速度もイマイチだったのでもっと使い勝手の良いものが欲しかった。

そこでPull Requestに対して自動的にチェックしてくれると便利そうだったので以下のようなツールを考えてみた。

  1. git diffの結果から、
  2. 変更のあったシンボルの位置を特定し、
  3. (決められたところまで)呼び出し元を辿る

うまく実装できれば公開するかもしれない。

3は前回のblogに書いた方法でLSPのcallHierarchy/incomingCallsを繰り返していけば良いので処理的には比較的簡単。

daisuzu.hatenablog.com

2のシンボルの位置はもっと簡単な方法がありそうな気もしたけど、ast.FileDeclsを使って変更行が範囲内かどうかを調べれば良さそう。

package a

func sum(a, b int) int {
    total := a + b
    return total
}

このsum関数の場合は以下のようになっているので、

Decls: []ast.Decl (len = 1) {
.  0: *ast.FuncDecl {
.  .  Name: *ast.Ident {
.  .  .  NamePos: a.go:3:6
.  .  .  Name: "sum"
.  .  .  Obj: *ast.Object {
.  .  .  .  Kind: func
.  .  .  .  Name: "sum"
.  .  .  .  Decl: *(obj @ 7)
.  .  .  }
.  .  }
.  .  Type: *ast.FuncType {
.  .  .  Func: a.go:3:1
.  .  .  Params: *ast.FieldList {
.  .  .  .  Opening: a.go:3:9
.  .  .  .  List: []*ast.Field (len = 1) {
.  .  .  .  .  0: *ast.Field {
.  .  .  .  .  .  Names: []*ast.Ident (len = 2) {
.  .  .  .  .  .  .  0: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:3:10
.  .  .  .  .  .  .  .  Name: "a"
.  .  .  .  .  .  .  .  Obj: *ast.Object {
.  .  .  .  .  .  .  .  .  Kind: var
.  .  .  .  .  .  .  .  .  Name: "a"
.  .  .  .  .  .  .  .  .  Decl: *(obj @ 22)
.  .  .  .  .  .  .  .  }
.  .  .  .  .  .  .  }
.  .  .  .  .  .  .  1: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:3:13
.  .  .  .  .  .  .  .  Name: "b"
.  .  .  .  .  .  .  .  Obj: *ast.Object {
.  .  .  .  .  .  .  .  .  Kind: var
.  .  .  .  .  .  .  .  .  Name: "b"
.  .  .  .  .  .  .  .  .  Decl: *(obj @ 22)
.  .  .  .  .  .  .  .  }
.  .  .  .  .  .  .  }
.  .  .  .  .  .  }
.  .  .  .  .  .  Type: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:3:15
.  .  .  .  .  .  .  Name: "int"
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  .  Closing: a.go:3:18
.  .  .  }
.  .  .  Results: *ast.FieldList {
.  .  .  .  Opening: -
.  .  .  .  List: []*ast.Field (len = 1) {
.  .  .  .  .  0: *ast.Field {
.  .  .  .  .  .  Type: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:3:20
.  .  .  .  .  .  .  Name: "int"
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  .  Closing: -
.  .  .  }
.  .  }
.  .  Body: *ast.BlockStmt {
.  .  .  Lbrace: a.go:3:24
.  .  .  List: []ast.Stmt (len = 2) {
.  .  .  .  0: *ast.AssignStmt {
.  .  .  .  .  Lhs: []ast.Expr (len = 1) {
.  .  .  .  .  .  0: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:4:2
.  .  .  .  .  .  .  Name: "total"
.  .  .  .  .  .  .  Obj: *ast.Object {
.  .  .  .  .  .  .  .  Kind: var
.  .  .  .  .  .  .  .  Name: "total"
.  .  .  .  .  .  .  .  Decl: *(obj @ 67)
.  .  .  .  .  .  .  }
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  .  TokPos: a.go:4:8
.  .  .  .  .  Tok: :=
.  .  .  .  .  Rhs: []ast.Expr (len = 1) {
.  .  .  .  .  .  0: *ast.BinaryExpr {
.  .  .  .  .  .  .  X: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:4:11
.  .  .  .  .  .  .  .  Name: "a"
.  .  .  .  .  .  .  .  Obj: *(obj @ 27)
.  .  .  .  .  .  .  }
.  .  .  .  .  .  .  OpPos: a.go:4:13
.  .  .  .  .  .  .  Op: +
.  .  .  .  .  .  .  Y: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:4:15
.  .  .  .  .  .  .  .  Name: "b"
.  .  .  .  .  .  .  .  Obj: *(obj @ 36)
.  .  .  .  .  .  .  }
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  .  1: *ast.ReturnStmt {
.  .  .  .  .  Return: a.go:5:2
.  .  .  .  .  Results: []ast.Expr (len = 1) {
.  .  .  .  .  .  0: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:5:9
.  .  .  .  .  .  .  Name: "total"
.  .  .  .  .  .  .  Obj: *(obj @ 72)
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  }
.  .  .  Rbrace: a.go:6:1
.  .  }
.  }
}

token.FileSetPosition()Pos()End()を渡せば行番号を取得できる。

1のgit diffは良い方法が思いつかなかったので標準出力をパースしてみる。
余計な情報は減らしておきたいので--diff-filter=Mで変更のあったファイルのみを対象にし、-U0で変わった行だけを出力する。

diff --git a/a.go b/a.go
index 2d1b2ea..4cceff6 100644
--- a/a.go
+++ b/a.go
@@ -4,2 +4 @@ func sum(a, b int) int {
-       total := a + b
-       return total
+       return a + b

最低限必要なのは、

  • bのファイル名(a.go)
  • @@の行の+の後ろにある数字(4)
  • @@以降で+から始まる行がいくつあるか(1つ)

の3つ。
ファイルをparser.ParseFileで開いたら行番号を起点として変更行の数だけシンボルの位置(このケースだとa.go:3:6のみ)を探していく。

ただ、これだけだと関数の位置を移動しただけでも影響があることになってしまうのでこういった部分は除外したい。
そして完全に新規で追加されたシンボルはきっとどこかで使われているはずなのでこれも除外したい。

また、構造体やgoroutineの呼び出しはcallHierarchyが使えないのでtextDocument/referencesや2を使って関数の位置を調べる必要がある。

あとは結果をどう見せるのかも悩ましいところ。
GitHubでリンクになっているのが良いかもしれないし、さらに他のツールと連携することを考えるとgo vetのような形式やJSONの方が扱いやすいかもしれない。

*1:アーキテクチャがおかしいのかもしれないけど、実際そうなってしまっているので。。。