🚧

カスタムLintでガードレールを強固にする(Go)

に公開

はじめに

これは エンジニアと人生 Advent Calendar 2025 11日目の記事です。

今回書くのはGoのLintを固め、AIを使った開発の事故を減らそうというお話です。

なぜlinterを強固にしたかったか?

主にAI駆動での開発の中で、AIエージェントは割とよくルールを忘れます。
「こういう書き方で書いてくれ」みたいなことを伝えても、無視されたコードが割と生成されている印象があります。
また、今年は仕様駆動開発(Spec Driven Development)のような手法でのAIを使った開発手法が出てきて、確かに開発の安定感は増したように思いますが、それでもAIの生成するコードはエージェントが違ったり、細かなプロンプトのやり取りでの違いやタスクの違いによって生成されるコードは微妙なパラダイムの違いが生まれ、不要なレビュー時間が発生してしまっている実態があると感じました。

AI駆動での開発ではlintやtest,CIを使ってAIの開発にガードレールを設けることで、品質を担保する考えも今年さらに根強くなったと思います。
私もこのあたりはやっていたのですが、それでも上述したようなコードが機能によって書き方の特徴が違って作られるみたいなことが出ていました。

ガードレールのチェック観点を増やすのではなく、ガードレールの重要な要素の一つにコーディングのルールを織り交ぜたLintを入れることで、ルールとして伝えなくとも、コマンドの失敗で自動で理解して、期待値に沿った実装になるのではないか?という仮説を立てたのがきっかけでした。

ちなみに、こちらの発表でかなり閃きをもらいました。
いわばそれをGoでもやってみたい!というところから始まっています。

カスタムリンターとは?

Go言語には公式のカスタムリンター作成フレームワークgo/analysis)があります。
標準のlinterと同じ仕組みで、プロジェクト固有のルールを実装できます。

標準linterとの違い

項目 標準linter カスタムlinter
提供元 Go公式 プロジェクト独自
ルール 汎用的 プロジェクト固有
go vet, staticcheck gwtlint
実行方法 go vet go vet -vettool=./bin/gwtlint

どちらも同じgo/analysisフレームワークで動作します。

設定したlint

今回はlintを4つの項目に分けるように設定しました。
また、このlintは現在発展途上ですが、継続的なカスタマイズを予定しています。
(その分、リント処理時間の増加は避けられませんが)

1. check/version

  • Goバージョンが.go-versionファイルと一致しているか確認
  • これは実行環境の問題でもあるが、Claude Codeだと動くがCodexだと動かない事象があり、そのためにバージョンをチェックするように入れたもの

2. golangci-lint(標準リンター)

  • govet: セマンティック分析(手動レビュー推奨)
  • gocritic: コンテキスト依存の分析(手動レビュー推奨)
  • staticcheck: ロジック分析(手動レビュー推奨)など

3. lint/gwtlint(カスタムリンター - 5つのアナライザー)

  • CommentAnalyzer:
    • テストコード内で// given// when// thenコメントが順序通りにあるかなど
    • これがない時、テスト関数の中で自由に記述されており、何を検査しているのかがわからないことがあった
  • NamingAnalyzer
    • テストコード内での表記に揺れが見られた。具体的には:
      • want: 期待値を表す(古い慣習)
      • expected: 期待値を表す(推奨)
      • actual: 実際の値
      • output: 出力値(曖昧なため統一対象)
    • それらを統一させたかった
    • テストにおいては、アサーションする箇所も自由に作られてしまい、テストごとに何を検査してるかをコードを深く読まないとわからなかった
  • UseCaseInterfaceAnalyzer
    • UseCaseインターフェースは1つのメソッドExecuteのみを持つ
    • この設定がないときは、既存コードに沿って作ってくれるケースもあったが、その時々で自由に構造を作られてしまっていた
  • TableTestAnalyzer
    • Table-drivenテストのケース構造体にexpectedフィールドが必須とさせた
    • アサーションのコードがまばらになってしまっていて、何を検査できているのか不明だったため、統一させた
  • DomainTestAnalyzer
    • Getterのテストを作るのは禁止など

4. lint/unittestlint(カスタムリンター)

  • Analyzer
    • ユニットテストが作られてない場合はエラーとするなど

詳細は書いていないものもありますが、現時点ではテストコード関連が多いですね。
私は業務で利用していたのはgolangci-lintまででカスタムリンターは今回初めて組み込みました。

カスタムリンターの構成

カスタムリンターは仕様駆動開発で構築したツールです。
今回、記事執筆を機に実装の詳細を改めて検証することができました。

構成

  tools/gwtlint/
  ├── cmd/
  │   └── main.go                       # エントリーポイント(5つのAnalyzerを統合)
  │
  ├── analyzer.go                       # ① CommentAnalyzer(GWTコメントチェック)
  ├── naming.go                         # ② NamingAnalyzer(sut/actual/expected統一)
  ├── analyzer_usecase_interface.go     # ③ UseCaseInterfaceAnalyzer(Execute単一メソッド)
  ├── analyzer_table.go                 # ④ TableTestAnalyzer(テーブル駆動テスト構造)
  ├── domain_test_rule.go               # ⑤ DomainTestAnalyzer(ドメインテスト品質)
  │
  ├── rules_table.go                    # テーブルテストのルール定義
  ├── rules_usecase_interface.go        # UseCaseインターフェースのルール定義
  └── autofix.go                        # 自動修正ロジック

上記のような構成でtoolとして動かせるようにしておき、makeコマンドで実行できるようにしていました。

  lint: check/version lint/gwtlint lint/unittestlint
  	$(GOLANGCI_LINT) run

  check/version:
  	$(GO) run ./tools/versioncheck/cmd/main.go

  lint/gwtlint:
  	$(GO) build -o bin/gwtlint ./tools/gwtlint/cmd
  	$(GO) vet -vettool=$(PWD)/bin/gwtlint ./...

  lint/unittestlint:
  	$(GO) build -o bin/unittestlint ./tools/unittestlint/cmd
  	$(GO) vet -vettool=$(PWD)/bin/unittestlint ./...

lint/unittestlintなどもありますが、ここではgwtlintだけを出しています。

実装例:CommentAnalyzer

比較的わかりやすそうなCommentAnalyzerのコードを記載します。
このLintでは以下のように修正させることができます。

  // ❌ NG例
  func TestCreateUser(t *testing.T) {
      user := NewUser("Alice")
      err := user.Validate()
      assert.NoError(t, err)
  }

  // ✅ OK例
  func TestCreateUser(t *testing.T) {
      // given
      user := NewUser("Alice")

      // when
      err := user.Validate()

      // then
      assert.NoError(t, err)
  }

コードは以下のようになっています。

analyzer.go
  // analyzer.go

  // ① Analyzerの定義
  var CommentAnalyzer = &analysis.Analyzer{
      Name: "gwtcomment",
      Doc:  "checks for required // given, // when, // then comments in test functions",
      Run:  runCommentAnalyzer,
  }

  // ② メイン処理
  func runCommentAnalyzer(pass *analysis.Pass) (interface{}, error) {
      for _, file := range pass.Files {
          filename := pass.Fset.Position(file.Pos()).Filename
          // _test.goファイルのみ対象
          if !strings.HasSuffix(filename, "_test.go") {
              continue
          }

          ast.Inspect(file, func(n ast.Node) bool {
              fn, ok := n.(*ast.FuncDecl)
              if !ok || !isTestFunction(fn) {
                  return true
              }

              checkFunctionBody(pass, file, fn)
              return true
          })
      }
      return nil, nil
  }

  // ③ Test関数判定
  func isTestFunction(fn *ast.FuncDecl) bool {
	if fn.Name == nil || !strings.HasPrefix(fn.Name.Name, "Test") {
		return false
	}
	// *testing.T パラメータを持つか確認
	if fn.Type.Params == nil || len(fn.Type.Params.List) == 0 {
		return false
	}
	for _, param := range fn.Type.Params.List {
		if sel, ok := param.Type.(*ast.StarExpr); ok {
			if selectorExpr, ok := sel.X.(*ast.SelectorExpr); ok {
				if ident, ok := selectorExpr.X.(*ast.Ident); ok {
					if ident.Name == "testing" && selectorExpr.Sel.Name == "T" {
						return true
					}
				}
			}
		}
	}
	return false
  }

  // ④ コメントチェック
func checkFunctionBody(pass *analysis.Pass, file *ast.File, fn *ast.FuncDecl) {
	if fn.Body == nil {
		return
	}

	// 関数本体のコメントを収集
	comments := collectCommentsInRange(file, pass.Fset, fn.Body.Pos(), fn.Body.End())

	// given/when/then の位置を検索
	givenPos, whenPos, thenPos := findGWTPositions(comments)

	// t.Run の呼び出しを検索し、それぞれをチェック
	subtestRanges := findSubtestRanges(fn.Body)

	// サブテストがある場合、メイン関数のチェックをスキップする可能性がある
	// ただし、サブテスト外にもコードがあればチェックが必要

	if len(subtestRanges) == 0 {
		// サブテストがない場合、メイン関数全体をチェック
		if !validateGWTOrder(givenPos, whenPos, thenPos) {
			if givenPos < 0 || whenPos < 0 || thenPos < 0 {
				pass.Reportf(fn.Pos(), "test function %s missing required comments: need // given, // when, // then in order", fn.Name.Name)
			} else {
				pass.Reportf(fn.Pos(), "test function %s has wrong comment order: need // given, // when, // then in order", fn.Name.Name)
			}
		}
	} else {
		// サブテストがある場合、各サブテストをチェック
		for _, subtestRange := range subtestRanges {
			subtestComments := collectCommentsInRange(file, pass.Fset, subtestRange.start, subtestRange.end)
			gPos, wPos, tPos := findGWTPositions(subtestComments)
			if !validateGWTOrder(gPos, wPos, tPos) {
				pass.Reportf(subtestRange.start, "subtest missing required comments: need // given, // when, // then in order")
			}
		}
	}
}

// ⑤ コメント抽出
func collectCommentsInRange(file *ast.File, fset *token.FileSet, start, end token.Pos) []string {
	var comments []string
	for _, cg := range file.Comments {
		for _, c := range cg.List {
			if c.Pos() >= start && c.End() <= end {
				comments = append(comments, strings.ToLower(strings.TrimSpace(strings.TrimPrefix(c.Text, "//"))))
			}
		}
	}
	return comments
}

  // ⑥ GWT位置検出(約20行)
func findGWTPositions(comments []string) (givenPos, whenPos, thenPos int) {
	givenPos, whenPos, thenPos = -1, -1, -1
	for i, c := range comments {
		c = strings.TrimSpace(c)
		switch c {
		case "given":
			if givenPos < 0 {
				givenPos = i
			}
		case "when":
			if whenPos < 0 {
				whenPos = i
			}
		case "then":
			if thenPos < 0 {
				thenPos = i
			}
		}
	}
	return
}

  // ⑦ 順序検証(5行)
  func validateGWTOrder(givenPos, whenPos, thenPos int) bool {
      if givenPos < 0 || whenPos < 0 || thenPos < 0 {
          return false
      }
      return givenPos < whenPos && whenPos < thenPos
  }

最終的にはmakeに設定したコマンドを実行します。
もしgiven,when,thenのコメントが適切にない場合は以下のようなエラーになります。

> make lint/gwtlint

internal/domain/user_test.go:10:1: test function TestCreateUser missing required comments: need // given, // when, // then in order

このようにして、ここのlintでは期待しているテストの構造にないものはエラーになり修正を強制させることができるようになりました。

課題

上の一例だけではなく、いろんな設定を追加することができ、
コードやテストの内容が自分の意図してるものにできるだけ寄せられるようになりました。
ただまだ課題は多く、例えば、以下のようなテストを完全に排除できていません。

// 実装
// MemorialPlanID is the unique identifier for a memorial plan.
type MemorialPlanID string  
  
// String returns the string representation of MemorialPlanID.  
func (id MemorialPlanID) String() string {  
    return string(id)  
}

// テスト
func TestMemorialPlanID_String(t *testing.T) {  
    testcases := map[string]struct {  
       input       MemorialPlanID  
       expected    string  
       expectedErr error  
    }{  
       "正常系: 通常のID": {  
          input:       MemorialPlanID("plan-123"),  
          expected:    "plan-123",  
          expectedErr: nil,  
       },  
       "正常系: 空のID": {  
          input:       MemorialPlanID(""),  
          expected:    "",  
          expectedErr: nil,  
       },  
    }  
  
    for name, tc := range testcases {  
       t.Run(name, func(t *testing.T) {  
          // given  
  
          // when          
          actual := tc.input.String()  
          actualErr := error(nil)  
  
          // then  
          assert.Equal(t, tc.expected, actual)  
          assert.Equal(t, tc.expectedErr, actualErr)  
       })  
    }  
}


そもそもでいうと、このテストは本来であれば不要だと思います。
にもかかわらず、

  1. そもそもテストを作ってほしくない
  2. actualErrはテストケースでexpectedErrとチェックするために強要してるが、ない場合は無理に生成する必要がない

のようなテストが生まれてしまっています。
不足してるよりはマシではありますが、余剰な不要なテストが生成されると次のAIエージェントが誤った解釈をしてしまい、結果的にゴミが量産されてしまい品質が落ちてしまいます。

なので、この辺りの塩梅はうまくコントロールできる設定にできたらいいなと考えています。

最後に

課題はありつつ、まだまだ改善の余地はありますが、AIと協調することでlintを強固にすることができて、ガードレール駆動を強化できるようになったと思います。
私は個人開発も行っていますが、nextjsなどGo以外のアプリケーションにもこれらのLint設定をAIに読み込ませて、言語を変えて再現させてもらったりもしてますが、悪くない感じですね。

Lintをガードレールの軸にしてより良いコードを書けるようになったらいいなと思いました。コードを書いてる大半はAIですけどね!!

参考

https://pkg.go.dev/golang.org/x/tools/go/analysis
https://speakerdeck.com/wakye5815/lintnomideainikai-fa-sutairuwokou-kiip-merunoka

Discussion