Goコード品質基盤の内製化:ルールガードと静的解析でレビュー文化を自動化

  • golang
    golang
  • react
    react
  • expressjs
    expressjs
2023/11/21に公開
この記事はドラフト版です。

まとめ

  • 問題:レビュー指摘が属人化し、品質担保が個人スキルに依存していた。
  • 対応:静的解析ツール群を統合し、定型・口伝ルールを自動検知化。
  • 成果:レビュー負荷を削減し、品質基準をコードベースで再現可能にした。

発生概要

コードレビューの品質が属人的になり、プロジェクト全体の品質基準が明文化されていなかった。
特にGoコードでは、レビュー指摘の多くが「テスト命名」「構造体タグ」「危険API使用禁止」などの定型的な“口伝ルール”に集中しており、担当者によって指摘内容や精度に差が生じていた。

レビュー時間の約40%が機械判定可能な項目に費やされ、本質的な設計・性能レビューに十分なリソースを割けない状態が続いていた。
さらに、os.Exitpanic などの利用制限ルールがドキュメント化されておらず、運用フェーズで予期せぬ挙動を引き起こすケースも確認された。

この状況を受け、静的解析エコシステムを活用した「レビュー文化の自動化」と「ルールのコード化」による品質基盤整備を開始した。

調査フェーズ

まず、過去6か月分のコードレビューコメントを収集し、指摘内容を「再現可能性」「自動化可能性」「影響範囲」の3軸で分類した。
結果、レビュー全体のうちかなりの割合が静的解析で検知可能な定型パターンに該当した。

次に、既存の golangci-lint に含まれるルールを精査し、既製Lintで検出可能な範囲と独自ルール化が必要な領域を切り分けた。
後者には、プロジェクト特有の構造体タグ形式・認可チェック漏れ・テスト安定化ルールなどが含まれた。

  • 構造体タグ統一:jsondb タグの命名不一致 (user_id / UserID)
  • 危険API検出:os.Exitpanic の直接呼び出し
  • テスト安定化:time.Sleep に依存する不安定テスト
  • 認可チェック漏れ:Authorize() 呼び出しの抜け

調査段階では以下の観点で評価を行い、自動化対象を決定した。

  • 精度:誤検知率10%以下を目標
  • 保守性:ルール追加・変更が容易であること
  • 教育効果:開発者がルールの背景を理解できること

それぞれについて、既存ツール・自作ルール・組み合わせの3方式を比較し、まずは ruleguard で試験的なルール化を実施した。

検証フェーズ

warn 導入 → 誤検知調整 → error 昇格の3段階で検証した。
サンプル/実プロジェクト双方に適用し、検出再現率・誤検知率・CI所要時間を計測した。
結果、定型指摘の再現率は 78% → 95%、誤検知率は 14% → 6%。CIは +28〜35秒/ジョブ の増分で収まった。

手順(サマリ)

  1. ベースライン作成(現状違反を issues-baseline.json に固定)
  2. golangci-lint で既製ルールのカバレッジ確認
  3. ruleguard で定型ルール試作、誤検知を抑制
  4. 高価値ルールを go/analysis で内製化し型解析を活用
  5. SARIF出力でPR可視化、週次で閾値/ルール更新

昇格ポリシー

2週連続で誤検知率 ≤ 7% を満たしたルールのみ issues-exit-code: 1 に昇格
昇格前に fixup PR(自動整形・ルール対応)を先行投入して開発停滞を回避

golangci-lint を核に、staticcheck, revive, gocritic, gocyclo/gocognit/nestif/funlen を有効化。

ruleguard で“口伝ルール”を 2〜3 本(テスト安定化/タグ統一/危険 API 禁止)。

価値が高いものから go/analysis に格上げ(認可通過保証、監査ログ網羅、型に基づく置換)。

メトリクスは 既製ツール+SARIF で PR に自動貼り付け(増減を“見える化”)。

go/analysis の仕組み

Go公式の静的解析フレームワーク。
Analyzer を単位に依存関係(Requires)と実行本体(Run)を定義し、コンパイル単位ごとに Pass(AST・Types情報・レポートAPI)を受け取り診断を生成する。
診断は pass.Reportf、自動修正は SuggestedFix でPRレビューに還元できる。

Analyzer の基本構造

// analyzer/example/analyzer.go
package example

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{
  Name: "noexit",
  Doc:  "reports os.Exit usage in app code",
  Requires: []*analysis.Analyzer{
    inspect.Analyzer, // 依存(AST走査ユーティリティ)
  },
  Run: func(pass *analysis.Pass) (any, error) {
    insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
    insp.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(n ast.Node) {
      call := n.(*ast.CallExpr)
      // 簡易判定: os.Exit(...) を検出
      if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
        if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "os" && sel.Sel.Name == "Exit" {
          pass.Reportf(call.Pos(), "avoid os.Exit; return error instead")
        }
      }
    })
    return nil, nil
  },
}

型情報・高精度化

types.Info を使うとパッケージ名や別名importに影響されずに判定できる。
pass.TypesInfo.TypeOf, pass.TypesInfo.ObjectOf で解決し、関数呼び出しのシンボルをフル修飾で比較する。

import "go/types"

func isCallTo(pass *analysis.Pass, call *ast.CallExpr, pkg, name string) bool {
  sel, ok := call.Fun.(*ast.SelectorExpr)
  if !ok { return false }
  obj := pass.TypesInfo.ObjectOf(sel.Sel)
  if obj == nil { return false }
  if fn, ok := obj.(*types.Func); ok {
    if fn.Pkg() != nil && fn.Pkg().Path() == pkg && fn.Name() == name {
      return true
    }
  }
  return false
}

SuggestedFix(自動修正)

診断と併せて修正候補を返すと、エディタ/CIで自動修正が可能になる。
analysis.TextEdit を構築し SuggestedFixes として返す。

pass.Report(analysis.Diagnostic{
  Pos: pos, End: end,
  Message: "use context-aware logger",
  SuggestedFixes: []analysis.SuggestedFix{{
    Message: "replace with log.From(ctx)",
    TextEdits: []analysis.TextEdit{{
      Pos: pos, End: end, NewText: []byte("log.From(ctx)"),
    }},
  }},
})

複数 Analyzer の合成と依存

Requires で他のAnalyzer結果を再利用できる(例:inspect の結果)。
社内共通ルールはモジュール化して、各リポジトリの main(単体実行)に差し込む。

// cmd/noexit/main.go
package main

import (
  "golang.org/x/tools/go/analysis/singlechecker"
  "your.org/analyzers/noexit"
)

func main() { singlechecker.Main(noexit.Analyzer) }

テスト(analysistest)

analysistest を使うと「入力コード → 期待診断」を宣言的に検証できる。
// want "message" コメントが診断の期待位置・文言になる。

// analyzer/example/analyzer_test.go
package example_test

import (
  "testing"
  "golang.org/x/tools/go/analysis/analysistest"
  "your.org/analyzers/example"
)

func TestAnalyzer(t *testing.T) {
  testdata := analysistest.TestData()
  analysistest.Run(t, testdata, example.Analyzer, "a") // testdata/src/a/...
}
// testdata/src/a/main.go
package a

import "os"

func f() {
  os.Exit(1) // want "avoid os.Exit"
}

事前計測とチューニングの勘所

  • 対象ノードを絞る(Preorder の型配列を最小に)。
  • 依存 Requires は最小限に(inspect, typesinfo など必要十分)。
  • パス解決・文字列処理はホットパスを避け、判定用ヘルパーを共有化。
  • CIでは差分解析(変更ファイル限定)+ナイトリーでフルスキャン。

移行計画と再発防止

導入にあたっては「CIに負荷をかけず、開発を止めない」を原則とした段階的移行を設計した。
初期段階では warn 運用とし、誤検知率を監視しながら徐々に厳格化。
安定したルールのみ error に昇格し、既存コードを fixup PRで自動修正してから強制適用した。

段階的移行プロセス

  1. 段階1:検出のみ — issues-exit-code: 0 とし、検出内容をSlack通知に限定
  2. 段階2:警告レベル — CI結果に出力、誤検知報告を週次で集計
  3. 段階3:強制適用 — 誤検知率7%以下を2週維持したルールをerror
  4. 段階4:Analyzer統合 — go/analysisルールを独立パッケージ化し、各サービス共通CIへ移行

運用・再発防止策

  • ルール管理リポジトリを分離し、ルール更新をPull Request経由で可視化
  • 誤検知ログ収集をCIで自動化し、月次レビューで閾値調整
  • SARIF形式出力を採用し、PRレビュー画面に静的解析結果を直接表示
  • 新規サービスのスキャフォールドにルールテンプレートを組み込み、初期から一貫した品質基準を担保
  • ドキュメント整備:各ルールに「背景・意図・回避例」を添付し、教育コストを低減

この体制により、ツール更新やルール拡張によるブレを最小限に抑え、「ツールがレビュー文化を守る」状態を持続可能な形で実現した。

結果・成果

定量的成果(指標ベース/数値非開示:Goコード品質基盤)

以下の指標をベースに評価・改善を行いました(機密保持のため数値は非開示)。各指標には意図と計測方法を付記します。

指標 補足(意図・読むポイント) 計測方法・注意点(例)
レビュー指摘再現率 静的解析(Analyzer)が人レビューの指摘をどれだけ代替できているか。レビュー文化の“自動化率”。 PRレビューコメント(タグ: style, safety, perf 等)とAnalyzer検知結果を照合。時系列で追跡。
誤検知率(False Positive) ノイズの少なさ。開発者体験の鍵。 Analyzer検知→won’t fix/false-positive ラベル率を集計。ルール単位で週次モニタ。
定型レビュー件数(削減率) “指摘の自動化” による人手レビューの省力化。 PRコメントのうちテンプレ指摘(定型句・bot由来)を自動分類し件数を集計。
CIオーバーヘッド(ビルド時間差) 品質担保の追加コストが許容範囲か。 Qualityジョブのあり/なしでCI実行時間を計測(p50/p95/p99)。並列度やキャッシュ条件を固定。
Analyzerルール数/共通化率 自作ルールの充実度と横展開度。 ルール総数と社内共通モジュール化の比率を管理レポジトリで自動算出。
重大度別ブロック率 リリース阻害の最小化と安全担保のバランス。 error/warn/info 重大度ごとのブロック発生率をCI結果から抽出。SLO超過時に緩和シナリオを検証。

定性的成果

  • レビュー文化の定着
    「なぜこのルールが存在するのか」を明文化することで、レビュー指摘が再現可能になり、新規メンバーでも短期間で同水準の品質基準を維持できるようになった。

  • 品質とスピードの両立
    ルールの適用範囲を段階管理し、強制適用前に自動修正を挟む運用を確立。開発停滞を招かずに自動化精度を高めるサイクルを構築できた。

  • チーム間の再利用性向上
    自作Analyzerをモジュール化して他プロジェクトへ転用し、組織全体で一貫した品質基準・セキュリティ方針を共有できる基盤を整備した。

  • 教育・レビュー負荷の軽減
    暗黙知として扱われていた口伝ルールをコードで表現したことで、新人教育・レビュー育成コストを削減し、属人化を排除できた。

参考

項目 ruleguard だけで可 go/analysis 推奨 既製リンター/別手段
レイヤー越境ガード(domain→infra禁止) △(ファイルパス/インポートに単純条件なら可能) ◎(パッケージ依存グラフの厳密検証・例外規則) depguard なども選択肢
認可・権限の様式(scope チェック漏れ) △(ハンドラ直下の呼び出し存在チェック程度) ◎(型情報・制御フローを見て「必ず通る」を保証)
監査ログ/トレース必須(入口/出口) △(入口での呼び出し存在は可) ◎(早期 return 分岐・エラー経路も含め網羅性を担保)
テスト安定化(time.Sleep 禁止、t.Parallel() 強制) 一部は既製(testpackage, gocritic)でも代替可
エラーハンドリング方針(%w wrap、PII ログ禁止) ○(%w 促しは可) / △(PII はヒューリスティック) ◎(PII 判定・データフロー寄りは analyzer 向き) staticcheck 一部カバー
構造体タグ統一(json:"snake_case"、必須タグ) ◎(タグ文字列の正規表現判定が得意) revive でも一部可
API 移行(io/ioutilio/os 等の置換) ○(単純置換と import 補助程度) ◎(呼び出し形の差異・型差まで含む安全変換) 既製のマイグレーションツールがあれば併用
破壊的変更への一括修正 △(パターン一致が単純なら) ◎(型解決・引数再構成・安全な SuggestedFix)
大 struct の値渡し→ポインタ化 △(サイズ閾値なしの単純検出なら) ◎(go/types.Sizes でサイズ推定→提案)
CC/NPath/ネスト深度のレポート 既製(gocyclo, gocognit, nestif, funlen
PR への自動コメント(増減の可視化) CI 側機能(SARIF/GitHub Code Scanning, danger 等)