やってみた 2026年8月29日

AIエージェントに「今のGo」を教えるJetBrainsのCLIを、go.modを差し替えながら測ってみた

XECIN GoAIエージェントCLI開発ツール

GitHubのトレンドで JetBrains/go-modern-guidelines を見かけて、説明文の一行で手が止まりました。「Help AI coding agents write modern Go」。

コーディングエージェントに Go を書かせると、for i := 0; i < n; i++ が返ってくる。slices.Contains で済むところに手書きのループが生える。私も何度か「それ、min で書けます」とレビューコメントを打った経験があります。指摘するたびに、これは人間が毎回やる仕事なのだろうか、と思っていました。

そこに JetBrains が出してきたのが、エージェント向けに「そのプロジェクトの Go バージョンで使える現代的な書き方」を返すだけの小さな CLI です。やっていることは単純そうに見えるので、逆にどこまで真面目に作られているのかが気になりました。使い捨てのコンテナで実際に動かして、go.mod を差し替えながら出力がどう変わるかを測っています。

結論から言うと、コア部分はかなり素直に作られていました。ただ、バージョンをどう解決するかの挙動に、実プロジェクトで踏みそうな穴が1つありました。

試した環境

ホストを汚したくないので、検証は使い捨ての Docker コンテナの中だけで完結させています。以下の数値はすべてその中での実測です。

  • 対象: JetBrains/go-modern-guidelines(HEAD 40781f167719913666fe2a7dc1c77ea6f256df0a / 2026-08-19、Apache-2.0、リリース済みタグは v0.1.1)
  • ベースイメージ: golang:1.25-bookworm(Debian GNU/Linux 12、go1.25.12 linux/amd64)
  • リポジトリは 496KB・29ファイル(.git を除く)と、かなり小さい部類です

go.mod が go 1.25.0 を要求するので、ベースイメージはそこに合わせています。手癖で golang:1.22 を選ぶと最初のビルドで止まります。

# すべて使い捨てコンテナ内
git clone --depth 1 https://github.com/JetBrains/go-modern-guidelines.git   # 894ms
cd go-modern-guidelines
go build ./...                                    # 4,455ms(golang.org/x/mod v0.37.0 の取得込み)
CGO_ENABLED=0 go build -o /usr/local/bin/gmg .    # 449ms → 4,004,895 バイト
go test ./...
# ok  .../internal/cli        0.002s
# ok  .../internal/goversion  0.004s
# ok  .../internal/guidelines 0.002s

gmg
# go-modern-guidelines provides modern Go coding guidelines for AI agents, so they
# can write up-to-date Go code despite their knowledge cutoff.
#
# Commands:
#   list [--go-version <version> | --file-path <path> | <path>]
#   explain [--guideline-id <id>]... <id>...

依存は golang.org/x/mod の1本だけ。指針の実体は internal/guidelines/guidelines.json に入っていて、42,829バイト・54エントリでした。LLM は一切呼びません。ここは好みが分かれるところですが、私はこの「ネットワークもモデルも要らない」構成をかなり評価しています。エージェントの周辺ツールが軒並み従量課金になっていく中で、決定的で無料な層が残っているのは、社内に入れる側からすると素直に助かるので。

go.mod のバージョンを1行ずつ変えて、返ってくる数を数えた

このツールの本体は「プロジェクトの Go バージョンを解決して、そのバージョンで使える指針だけを返す」ところにあります。なので、まずそこを測りました。

# 明示指定でバージョンを振ってみる
$ for v in 1.0 1.13 1.16 1.17 1.18 1.19 1.20 1.21 1.22 1.23 1.24 1.25 1.26 1.27 1.28 1.30 2.0; do
    echo "go$v $(gmg list --go-version $v | wc -l)"
  done
go1.0 1     go1.13 3    go1.16 3    go1.17 3
go1.18 6    go1.19 8    go1.20 13   go1.21 32
go1.22 37   go1.23 41   go1.24 45   go1.25 46
go1.26 48   go1.27 54   go1.28 54   go1.30 54   go2.0 54

# go1.16 で返るのはこの3行だけ
$ gmg list --go-version 1.16
errors_is: Use errors.Is(err, target) instead of err == target so wrapped errors are handled correctly.
time_until: Use time.Until(deadline) instead of deadline.Sub(time.Now()).
time_since: Use time.Since(start) instead of time.Now().Sub(start).

# guidelines.json の since_version 分布
$ grep -o '"since_version": "[^"]*"' internal/guidelines/guidelines.json | sed 's/.*: //' | sort -V | uniq -c
  1 "1.0"   1 "1.8"   1 "1.13"  3 "1.18"  2 "1.19"  5 "1.20"  19 "1.21"
  5 "1.22"  4 "1.23"  4 "1.24"  1 "1.25"  2 "1.26"   6 "1.27"

数字にすると分かりやすいです。

プロジェクトの go.mod返る指針の数前バージョンからの増分
go 1.2013+5
go 1.2132+19
go 1.2445+4
go 1.2754+6

Go 1.21 で一気に19個増えるのが目を引きます。min / max / clear の組み込み関数と、slices / maps パッケージがまとめて入った回なので、当然といえば当然です。全54個の3分の1がここに集中しています。

そしてもう一つ、私が数えて意外だったのがこちらです。Go 1.24 の時点で45個、1.27 で54個。つまり 1.25以降に追加された指針は9個しかない。

README の動機は「学習データのカットオフ」と「頻度バイアス」の2本立てで書かれていますが、実際に中身を数えると、後者の比重が圧倒的に大きい。モデルが知らない新機能を教えるツールというより、モデルが知っているのに使わない標準ライブラリを、もう一度突きつけるツールでした。正直なところ、私はこの比率のほうが実務的だと思っています。slices.Contains を知らないモデルより、知っていて使わないモデルのほうが、レビューで遭遇する頻度がはるかに高いので。

なお、存在しない go 1.99 や go 2.0 を渡しても、エラーにはならず54個(=全部)が返ります。将来のバージョンで頭打ちになるだけで、落ちません。

引数を省いた瞬間に、手元のツールチェーンを見に行っていた

ここが今回いちばん引っかかったところです。

最初、私はテスト用に go 1.21 と書いた go.mod を置いたディレクトリに cd してから gmg list を叩きました。返ってきたのは46個。1.21 なら32個のはずです。数を変えても、toolchain を足しても、go.mod を消しても、ずっと46。壊れているのかと思って --go-version 1.21 を明示したら、こちらはきちんと32個返ってくる。

種を割ると、46 は コンテナに入っている go1.25.12 の数でした。パスを渡さない list は、カレントディレクトリの go.mod を探しません。go env GOVERSION を叩いて、手元のツールチェーンのバージョンで答えていたわけです。

# 同じ go.mod(go 1.21)のディレクトリで、渡し方だけを変える
$ cd /tmp/p1 && gmg list | wc -l
46                       # ← ./go.mod は見ていない。go env GOVERSION(go1.25.12)由来
$ gmg list /tmp/p1 | wc -l
32                       # ← パスを渡すと go.mod を読む

# go が PATH に無い状態で引数も無しだと、黙らずに落ちる
$ cd /tmp/p1 && env PATH=/usr/bin:/bin /usr/local/bin/gmg list
cannot determine the Go version from local Go toolchain. Pass --go-version explicitly, for example --go-version=1.24
# rc=1

# モノレポ: go.mod は svc/ の下だけにある
$ gmg list /tmp/mono      | wc -l   # リポジトリのルートを渡した
46                                   # ← go.mod が見つからず、黙ってツールチェーンに落ちる(rc=0)
$ gmg list /tmp/mono/svc  | wc -l
32
$ gmg list --file-path /tmp/mono/svc/main.go | wc -l
32                                   # ← 編集対象の .go を渡すのが正解
$ gmg list --file-path /tmp/mono/web/main.go | wc -l
46                                   # ← go.mod の無い側は、やはり黙って46

internal/goversion/goversion.go を読むと、素直にそう書いてあります。パスから解決するときは findUp で go.mod を上へ探し、無ければ go.work を探し、それも無ければ resolveGoToolVersion に落ちる。落ちた事実は、エラーになったときにしかメッセージに出てきません。成功パスでは終了コード0で、警告も何も出ずに答えだけが返ります。

これが実務で効いてくるのは、モノレポです。go.mod がサービスごとのサブディレクトリにあって、エージェントがリポジトリのルートを渡した場合、上に向かって探しても go.mod は見つからない。すると CI やローカルに入っている Go のバージョンで指針が決まります。go 1.21 で固定しているサービスなのに、手元が 1.25 なら46個返ってきて、その中には wg.Go(Go 1.25)が混ざる。ビルドできないコードを勧められる形です。

もっとも、これは使い方で回避できます。同梱の SKILL.md を読むと、エージェントには「編集しようとしているファイルを渡すことを推奨する」と明記されていました。

Prefer passing the file you are about to edit: sh "<skill-dir>/scripts/run-tool.sh" list --file-path path/to/file.go

ひとつ前のコードブロックの後半で試したとおり、.go ファイルを渡せばそのファイルのディレクトリから go.mod を探すので、モノレポでも正しい答えになります。推奨手順が推奨手順である理由が、そのまま挙動に出ている形です。今思えば、私が最初に引数なしで叩いたのがそもそもの間違いでした。とはいえ、外した側が黙って別の答えを返すのは、エージェントに渡すツールとしては少し気になります。ここは stderr に一行出るだけでも印象が変わりそうです。

go.mod の書き方そのものへの耐性は、思ったより広めでした。go 1.21rc1 も go 1.18beta2 も go 1.21 // comment も正しく解釈されます(それぞれ32 / 6 / 32)。go v1.21 のような不正な記述は modfile のパーサがそのまま蹴って、invalid go version 'v1.21': must match format 1.23.0 で終了コード1。go 1.21 と toolchain go1.27.0 が同居している場合は32、つまり言語バージョンのほうが勝ちます。Go の仕様どおりで、ここは安心して任せられます。

explain にはバージョンの門番がいない

list が短い一行を返すのに対して、explain は before / after の例つきで詳細を返します。

$ gmg explain errors_as_type
errors_as_type:
  Since: Go 1.26

  Summary:
    Use errors.AsType[T](err) when checking whether an error matches a specific type.

  Examples:
  Before:
    var pathErr *os.PathError
    if errors.As(err, &pathErr) { handle(pathErr) }
  After:
    if pathErr, ok := errors.AsType[*os.PathError](err); ok { handle(pathErr) }

$ gmg explain no_such_id
unknown Go modern code guideline ids: no_such_id. Available ids: generic_methods, json_v2,
promoted_field_literals, strings_bytes_cut_last, stdlib_uuid, url_clone, new_expression, ... (全54件)
# rc=1

未知のIDを渡すと、終了コード1で有効なID54件を全部並べてくれます。エージェント向けとしては親切な作りです。同じIDを2回渡しても重複せず1回だけ出ました。

ただし explain には --file-path も --go-version もありません。上の例のように、Go 1.26 の機能である errors_as_type は、プロジェクトの go.mod が何であろうと同じ説明を返します。バージョンの門番は list にしかいない、ということです。SKILL.md が「まず list を呼び、返ってきたIDについてだけ explain を呼べ」と繰り返し書いているのは、たぶんこの構造が理由でしょう。逆に言うと、エージェントが自分の記憶からIDを思い出して explain を直接叩いた場合、そのプロジェクトで使えない機能の解説がそのまま返ってきます。エージェントが手順を守る前提の設計です。

エージェントが実際にインストールするのは、mainではなくv0.1.1

もう一つ確認しておきたかったのが、配布のされ方です。プラグインの実体は plugin/skills/use-modern-go/scripts/run-tool.sh という薄いラッパで、初回呼び出し時にキャッシュディレクトリへ go install します。

インストールするバージョンは同じディレクトリの VERSION ファイルに固定されていて、中身は v0.1.1。今日の main(40781f1)ではありません。そして入れたバイナリの --version が VERSION と一致しなければ、そこで止まります。

$ GOFLAGS= GOWORK=off CGO_ENABLED=0 GOBIN=/opt/rel go install github.com/JetBrains/go-modern-guidelines@v0.1.1
# 1,941ms、4,005,007 バイト
$ /opt/rel/go-modern-guidelines --version
v0.1.1
$ gmg --version                      # 手元の main からビルドしたほう
v0.0.0-20260819173952-40781f167719

# リリース版 v0.1.1 と main の出力を突き合わせる
$ for v in 1.21 1.24 1.25 1.26 1.27; do
    echo "go$v released=$(/opt/rel/go-modern-guidelines list --go-version $v | wc -l) head=$(gmg list --go-version $v | wc -l)"
  done
go1.21 released=32 head=32    go1.24 released=45 head=45    go1.25 released=46 head=46
go1.26 released=48 head=48    go1.27 released=54 head=54
$ diff <(/opt/rel/go-modern-guidelines list --go-version 1.27) <(gmg list --go-version 1.27)
# 差分なし

今日の時点では、リリース版と main の出力は完全に一致していました。差分が出ることを半分期待して測ったので、ここは拍子抜けです。ただ、VERSION によるピン留めと、インストール後の --version 照合まで入れてあるのは、私が見てきたエージェント用ツールの中では丁寧な部類でした。エージェントが勝手に最新版を掴んで挙動が変わる、というケースもあったので、固定されているほうが運用としては読みやすいです。

ちなみに go install した実行体は --version が v0.1.1 を返すのに対し、手元でビルドしたものは疑似バージョンを返します。run-tool.sh は前者しか受け付けないので、ローカルの改造版を使いたいときは make dev-install と GO_MODERN_GUIDELINES_DEV=1 を通す別経路が用意されていました。ビルドを走らせるスクリプトを、エージェントが触るラッパから意図的に分離してある、とコメントにも書かれています。ここは設計として好きです。

速度は測るまでもない範囲でした。パスを渡す list を20回で25ms、引数なし(毎回 go env を起動する)で20回51ms。エージェントが編集のたびに呼んでも、体感には出ません。

試してみての所感

半日ほど触った範囲での評価です。

このツールは、やろうとしていることの割に驚くほど小さい。54個の JSON と、バージョンを解決する200行ほどのコードと、go install する薄いシェルスクリプト。それだけです。LLM も呼ばず、ネットワークも初回インストール以外は使わず、出力は毎回同じ。エージェント周辺のツールがどんどん重くなっていく中で、バージョンで絞るという1点だけに割り切ったのは正しい判断だと思っています。

一方で、私が社内のエージェントに入れるなら、渡し方を1行決めておきます。--file-path に編集対象の .go を渡す。これを外してリポジトリのルートやカレントディレクトリに任せると、go.mod を見ずに手元のツールチェーンで答える経路に静かに落ちます。落ちたことがログにも終了コードにも出ないので、気づくとしたら「なぜかビルドできない提案が返ってきた」ときです。

指針の中身については、Go 1.21 に3分の1が集中していて、1.25以降は9個。カットオフ後の新機能を教える道具というより、slices と maps を使えと言い続ける道具でした。それはそれで、私がレビューで打ってきたコメントとほぼ同じ内容なんですよね。人間が毎回言うより、ツールが毎回言うほうが健全です。

次に触るときは、実際のエージェントに接続して、list の出力がどれくらい生成コードに効くかを見てみたいところです。今回測ったのは「何を返すか」までで、「返したものが使われるか」は完全に別の話なので。