↗️

AI時代のモダンなGoの静的解析ツールの作り方① — inspector.Cursor型を使ったリファクタリング—

に公開

はじめに

ナレッジワークでソフトウェアエンジニアとして働いているtenntennです。Goで静的解析ツールを開発することが好きで個人でskeletonという静的解析ツールのスケルトンコードジェネレーターや300ページを超えるGoの静的解析に関する資料である16. 静的解析とコード生成 - プログラミング言語Go完全入門などを公開しています。

2026年2月21日に開催されたGo Conference mini 2026 in SENDAIでは、「静的解析からみるGoの過去と未来」という発表をキーノートとして行いました。
https://zenn.dev/knowledgework/articles/1e176605a3c6e3

ナレッジワークにおいても社内向けの静的解析ツールが存在しています。しかし、goパッケージやgolang.org/x/tools/goパッケージの最近の機能が使えていない部分があったり、生成AIが学習している静的解析ツールの作り方の情報も古かったりしたため、プロダクト開発の合間にコードのリファクタリングやModernize(最新化)を進めています。本稿ではリファクタリングに取り組んでいる中の一つである、inspector.Cursorを使った解析への移行について紹介します。

抽象構文木(AST)の走査

Goにおける静的解析の基本は本稿では扱いませんが、静的解析、主にLinterを開発した経験がある方のほとんどは抽象構文木(AST)の走査を行って解析しているでしょう。標準のgo/astパッケージで提供されているast.Inspect関数ast.Walk関数はASTを深さ優先探索で各ノードを走査できます。

ast.Inspect関数やast.Walk関数を用いるのも便利ですが、golang.org/x/tools/go/ast/inspectorパッケージでは、より便利なinspector.Inspector型が提供されています。inspector.InspectorもASTの走査を行う機能を提供していますが、一度ルートノードからすべてのノードを走査し、キャッシュしておくことで関数呼び出しの回数を減らしています。

Go1.12でgo vetコマンドにgolang.org/x/tools/go/analysisパッケージ(以降、go/analysisパッケージと記述)を利用したAnalyzer(解析器)が導入されました。その後のGoにおける静的解析ツールの開発では、go/analysisパッケージが提供するAnalyzer単位の開発が主流になりました。

そのため、inspector.Inspector型もgolang.org/x/tools/go/analysis/passes/inspectパッケージが提供するinspect.Analyzerから以下のように取得できるようになりました。

import (
	/* ... */

	"go/ast"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
	/* ... */
	Requires: []*analysis.Analyzer{
		inspect.Analyzer,
	},
}

func run(pass *analysis.Pass) (any, error) {
	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	filter := []ast.Node{
		(*ast.Ident)(nil),
	}
	inspect.Preorder(filter, func(n ast.Node) {
		ident, _ := n.(*ast.Ident)
		if ident == nil { return }
		/* ... */
	})
	return nil, nil
}

(*inspector.Inspector).Preorderメソッド(*inspector.Inspector).Nodesメソッド(*inspector.Inspector).WithStackメソッドを用いたASTの走査がよく用いられるようになりました。それぞれ次のような特徴があり、用途によって使い分けられます。

  • (*inspector.Inspector).Preorderメソッド: 型でノードを絞りこんで探索ができる
  • (*inspector.Inspector).Nodesメソッド: ゆき・かえりの走査や打ち切りが行える
  • (*inspector.Inspector).WithStackメソッド: そのノードまでのパスがスタック形式で取得可能

イテレータの登場とinspector.Cursor

Go1.18で型パラメータ(ジェネリクス)が導入され、Go1.23ではrange over func(いわゆるイテレータ)が導入されました。ジェネリクスやイテレータの登場により、静的解析ツールの開発は大きく2つの点で変わりました。

1つめは、ジェネリクスやイテレータを含んだコードを解析しなければならないようになった点です。これらは標準ライブラリでもslicesパッケージなどで当たり前のように用いられるようになったため、言語仕様をしっかり理解したうえで静的解析ツールを開発する必要があります。

2つめは、ジェネリクスやイテレータを使って静的解析ツールを開発できるようになった点です。(*inspector.Inspector).Preorderメソッドでは値引数で型情報を指定していましたが、型引数として型を指定できるようになりました。また、inspector.All関数のように、各ASTのノードをイテレータを使用してfor文で探索できるようになりました。

従来のinspectorパッケージでは、ASTのノード(ast.Node型を実装した型)をそのまま扱っていました。直感的で分かりやすい面はありますが、親ノードが取得したい場合など汎用性が欠ける面もありました。inspector.Cursor型はその弱点を補うために導入され、柔軟な探索が実現できます。

inspect.Analyzerから*inspector.Inspector型の値が取得できるため、RootメソッドからASTのルートノードに対応するinspector.Cursor型の値を取得できます。後述のコード例のようにinspect.Root().Preorder(...)と呼び出すことで、ルートノードから走査を開始できます。なお、go/analysisパッケージでは、Analyzer.Runフィールドの関数を並行に実行する可能性があるため、inspector.Cursor型はポインタで扱わず、イミュータブルな構造体として扱っていることに注意してください。

inspector.Cursor型は、ast.Nodeインタフェースを実装した型を直接扱うのとは異なり、次のようなメソッドを用いて柔軟な探索が可能です。

なかでもよく使うのがCursor.Preorderメソッドです。そのカーソルの指すノードから深さ優先探索でノードを走査し、引数で渡した値と型が一致するノードを対象にiter.Seq[inspector.Cursor]型のイテレータを構築します。従来の(*inspector.Inspector).Preorderメソッドのコールバックベースの走査を、for range文で記述できるようになります。

func run(pass *analysis.Pass) (any, error) {
	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	filter := []ast.Node{
		(*ast.Ident)(nil),
	}
	for cur := range inspect.Root().Preorder(filter...) {
		ident, _ := cur.Node().(*ast.Ident)
		if ident == nil { continue }
		/* ... */
	}
	return nil, nil
}

なお、Preorderメソッドが型パラメータを用いずに、値引数で型を指定している点を残念に思うかもしれません。これはGoの型パラメータが関数のみに対応している制約によるもので、*inspector.Inspector向けのinspector.All関数は次のように型パラメータで型を指定しています。

func All[N interface {
	*S
	ast.Node
}, S any](in *Inspector) iter.Seq[N]

interface{ *S; ast.Node }の部分は、型パラメータNに対する型制約を表し、型パラメータSのポインタかつast.Nodeインタフェースを実装しているものに制限しています。つまり、型パラメータN*ast.Identなどポインタレシーバでast.Nodeインタフェースを実装する型に限定されているという意味です。

メソッドに型パラメータを指定できない制限は、#77273で提案され、執筆時には承認されています。承認から実装・リリースまでにはまだ距離がありますが、早ければGo1.27あたりのリリースに含まれる可能性があります。

ただし、Cursor.Preorderメソッドのfilter ...ast.Nodeのように複数の型を渡すには、可変長型パラメータ(#66651)も必要になりますが、こちらは現時点で対応の予定はなさそうです。

それでも、generic methodだけでもCursor.Nodeメソッドの型パラメータ版(たとえばNodeAsのようなメソッド)が追加され、型引数を指定して具象型が返ってくるようになれば、型アサーションが不要になるため十分嬉しい改善です。

生成AIを用いたinspector.Cursor型へのリファクタリング

golang.org/x/tools/go/analysis/analysistestパッケージを使ったテストがしっかりあれば、生成AIにリファクタリングを実行させることは難しくありません。

テストが不十分な場合でも生成AIを用いてテストコードを追加できます。analysistestパッケージを使ったテストは、次のように実際のソースコードをテストデータとして指定するためテストケースの追加も容易です。リファクタリングによってデグレを防ぐために、実際のコードベースで出てきそうなパターンはテストケースとして追加しておくと良いでしょう。

github.com/gostaticanalysis/dupimportのテストから引用
package a

import (
	"fmt"
	fmt2 "fmt" // want "fmt is duplicated import"
)

func main() {
	fmt.Println("hoge")
	fmt2.Println("hoge")
}

golang.org/x/tools/go/ast/inspectorパッケージのドキュメントをClaude Codeなどに読ませることでリファクタリングを行ってくれます。必要があれば型パラメータイテレータの知識も与えると良いでしょう。

実際にナレッジワーク社内の静的解析ツールで試したところ、テストが揃っていればスムーズに進みました。たとえば、WithStackを使って親ノードをスタックから逆走査していた次のようなパターンは、

inspect.WithStack([]ast.Node{
	(*ast.CallExpr)(nil),
}, func(node ast.Node, push bool, stack []ast.Node) bool {
	/* ... */
})

func findEnclosingContext(pass *analysis.Pass, call *ast.CallExpr, stack []ast.Node) bool {
	for i := len(stack) - 1; i >= 0; i-- {
		switch parent := stack[i].(type) {
		case *ast.AssignStmt:
			return checkAssign(pass, call, parent)
		case *ast.CompositeLit:
			return checkComposite(pass, call, parent)
		}
	}
	return false
}

Cursor.Enclosingメソッドを使って、次のような形に書き換えられます。

for cur := range inspect.Root().Preorder((*ast.CallExpr)(nil)) {
	call := cur.Node().(*ast.CallExpr)
	/* ... */
}

func findEnclosingContext(pass *analysis.Pass, call *ast.CallExpr, cur inspector.Cursor) bool {
	for enc := range cur.Enclosing((*ast.AssignStmt)(nil), (*ast.CompositeLit)(nil)) {
		switch parent := enc.Node().(type) {
		case *ast.AssignStmt:
			return checkAssign(pass, call, parent)
		case *ast.CompositeLit:
			return checkComposite(pass, call, parent)
		}
	}
	return false
}

Cursor.Enclosingメソッドは、現在のカーソルから根に向かって祖先ノードを辿り、引数で渡した型に一致するノードを返すイテレータです。WithStackで必要だったスライスの逆走査が不要になり、型フィルタ付きで祖先ノードを直接イテレートできます。関数シグネチャもstack []ast.Nodeからcur inspector.Cursorに変わることで、呼び出し側もすっきりします。ここでは簡略化した例を示していますが、実際のコードベースでは複数のアナライザーにまたがる書き換えになるため、テストがしっかりあることが重要でした。

コードベースの最新化がされていれば、今後開発する静的解析ツールも生成AIがコードベースを参考にしてinspector.Cursor型を使って実装をしてくれるでしょう。

修正後は既存のコードベースにリファクタリングを行った静的解析ツールをローカルやCIで実行してみることを忘れないようにしましょう。コードベースのサイズが小さくて不安な場合は、大きめのコードベースを持つOSSにかけてみて、意図する挙動になるか(false positiveを出さないか)、パニックなどを起こさないかなどを確認すると良いでしょう。

また、Cursor APIへの移行を進めると、生成ファイル除外やテストファイル除外など、複数のアナライザーに散らばっていた共通処理の扱いが課題になりました。たとえば、これまで各アナライザーが個別にastutil.IsGeneratedで生成ファイルを除外していた処理を、どのような粒度で共通化するかといった設計判断は、生成AIに任せきりにせず方針を与えたほうが良い結果になりました。ナレッジワークでは、これらの共通処理をinterceptorパッケージとして切り出し、アナライザーの実装をより簡潔にするアプローチを取りましたが、詳細は続編の記事で紹介する予定です。

golang.org/x/tools/go/ast/edgeパッケージ

inspector.Cursor型と合わせて使うと便利なパッケージとして、golang.org/x/tools/go/ast/edgeパッケージがあります。inspector.Cursor.ParentEdgeメソッドを用いると、親ノードから現在のノードへのエッジについて情報を得ることができます。ParentEdgeメソッドは、edge.Kind型の値と親ノードにおけるその辺のインデックスを返します。

edge.Kind型は次のように宣言された型で、edge.BinaryExpr_Xedge.BinaryExpr_Yのような定数も宣言されています。

type Kind uint8

const (
	Invalid Kind = iota // for nodes at the root of the traversal

	ArrayType_Elt
	ArrayType_Len
	AssignStmt_Lhs
	AssignStmt_Rhs
	BinaryExpr_X
	BinaryExpr_Y
	/* ...略... */
)

ASTにおける辺(エッジ)とは、親ノードがその子ノードをどのような役割としているかを表しています。たとえば、x + yのような二項演算式は、*ast.BinaryExpr型の値で表現され、第1項のxに対応する子ノードである*ast.Ident型の値は、BinaryExpr.Xフィールド、同様に第2項のyBinaryExpr.Yフィールドに設定されます。そのため、二項演算式を表すノードは、第1項と第2項の2つの子ノードを持ち、それぞれに結ばれる辺はedge.Kind型の値としてはedge.BinaryExpr_Xedge.BinaryExpr_Yで表現されます。

ast.Nodeインタフェースは、統一的な方法で子ノードを取得する方法を提供していません。そのため、これまでgo/astパッケージやgolang.org/x/tools/go/astパッケージでは、ast.Walk関数やast.Inspect関数経由で実際に探索するしか親ノードとの関係(エッジ)を取得できませんでした。edgeパッケージの登場により、対象のノードが親ノードにどのように使われているのか簡単に解析できるようになりました。

たとえば、関数のボディを調べたい場合、これまでは*ast.FuncDecl型または*ast.FuncLit型のノードをトップダウン的に走査する必要がありましたが、edgeパッケージを使えば次のようにボトムアップ的にもアプローチが可能です。単体ではあまり旨味がなさそうですが、他の探索と組み合わせると効果を発揮しそうです。

func run(pass *analysis.Pass) (any, error) {
	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	filter := []ast.Node{(*ast.BlockStmt)(nil)}
	for cur := range inspect.Root().Preorder(filter...) {
		block, _ := cur.Node().(*ast.BlockStmt)
		if block == nil {
			continue
		}

		if kind, _ := cur.ParentEdge(); kind != edge.FuncLit_Body && kind != edge.FuncDecl_Body {
			continue
		}

		/* 関数ボディに関する処理 */
	}
	return nil, nil
}

おわりに

本稿では、ナレッジワーク社内で取り組んだ静的解析ツールのリファクタリングについて、inspector.Cursor型を中心に解説しました。Cursor.PreorderメソッドやCursor.Enclosingメソッドを活用することで、従来のコールバックやスタック逆走査をイテレータベースのシンプルなコードに書き換えられます。また、edgeパッケージと組み合わせることで、ボトムアップ的な解析も可能になります。interceptorパッケージを用いた共通処理の整理など、この他にも静的解析ツールのリファクタリングに取り組んでいるため、続編をお待ちください!

株式会社ナレッジワーク

Discussion