技術者のためのAI駆動開発の失敗事例 #1

AIコードレビューは、
「なんでも任せる」で失敗する

Flutter / custom_lintに学ぶ、静的解析とAIの役割分担

株式会社STVテック │ 公開日:2026年9月 │ 対象:AIコードレビューを導入・検討しているエンジニアリングマネージャー・テックリード

「コードレビューをAIに任せれば、レビュー負荷が減るはず」——そう考えて導入したものの、実際にはPRごとに大量のコメントが付き、それを一つずつ確認する時間の方が長くなった、という声を聞くことが増えました。

原因の多くは、AIの性能ではありません。「機械的に判定できる指摘」と「人間の判断が必要な指摘」を切り分けずに、すべてをAIに任せてしまっていることにあります。この記事では、Flutter開発で使われる静的解析の仕組み(custom_lint)を題材に、AIレビューに何を任せ、何を任せるべきでないかを整理します。

📌 この記事でわかること

AIコードレビューがかえってボトルネックになるメカニズム、「機械的に検出できる指摘」を見分ける軸、そしてFlutter・custom_lintを題材にした具体的な線引きの実例がわかります。

目次
1. 「AIに全部任せる」で起きていること
😓 想定されるケース

例えば、こんな場面を想定してみてください。AIコードレビューを導入したチームで、あるPRに数十件のコメントが自動で付いたとします。その大半は「利用用途が分かる変数名にすべき」「このWidgetは分割できる」といった一般論で、レビュアーはコメントを一つずつ読んで、対応が必須のものと、任意のものを仕分ける作業に時間を取られる——このように、レビュー全体のリードタイムが導入前より伸びてしまうケースは起こりえます。

この状況には、二つの問題が重なっています。一つは、AIレビューのために消費されるトークンと実行時間のコスト。もう一つは、大量の指摘によって人間レビュアーの負荷がむしろ増える、という逆説的な結果です。

よくある運用
とりあえず全部AIに読ませる
  • 静的解析ツールを整備せず、AIに一から指摘させる
  • コーディング規約もすべてAIへのプロンプトに詰め込む
  • 機械的な指摘も設計判断も同じ応答に混在させる
  • レビューコメントの取捨選択は人間任せ
目指したい運用
機械的な判定は機械に、判断はAIと人に
  • 命名規則・import制約・dispose漏れなどはlintで自動検出
  • AIレビューには、機械的なルール違反を解消した後のコードを渡す
  • 設計意図・要求充足など、判断が必要な指摘に絞って依頼
  • 人間レビュアーは最終的な合意形成に集中できる

「AIに何を聞くか」を設計しない限り、AIは効率化ではなくノイズ源になる。

2. なぜ「なんでもAI」に流れてしまうのか

なぜ多くのチームが、機械的に判定できることまでAIに聞いてしまうのでしょうか。背景には、いくつかの共通した思考パターンがあります。

導入初期の成功体験——AIに聞けば大抵何か答えが返ってくるため、「任せれば済む」という感覚が定着しやすい
既存の静的解析ツールとの役割分担を、導入前に設計していない
「AIは優秀」という期待から、決定的に判定できることまでAIに聞いてしまう
レビュープロセス全体を見直すコストを避け、AIを"足すだけ"で済ませようとする

結果として、本来ならlintやCIで機械的にブロックできる指摘までAIの応答に混ざり込みます。AIを呼び出すコストと、その出力を人間が読むコストの両方が、静かに積み上がっていくのです。

3. 線引きの軸:機械的に検出できるか、判断が必要か

「AIに任せてよいか」を判断する基準はシンプルです。ポイントは、「機械的判定 or AI・人間の判断」という二分法ではなく、担当を3つに分けて考えることです。lintとAIは競合しませんし、AIと人間も競合しません。それぞれ役割が違います。

担当何をするか典型例
lint・静的解析・自動テスト AST・型情報・命名パターンなど実行前に構文的に判定できる違反や、実行結果を基準と機械的に照合できる項目を検出する dispose漏れ・import制約違反(custom_lint)、型・null安全性違反(Dart Analyzer)、Widgetの振る舞い(flutter_test)、見た目の回帰(Golden Test)
AI 大量のコードを文脈付きで横断的に読み、人間が見落としそうな「違和感・不整合の候補」を挙げる 既存設計との不整合、類似処理との実装差異、エラー処理の抜け
人間 lintやAIが挙げた候補を、実際に対応すべき問題かどうか最終的に判断し、合意形成する 優先度判断、トレードオフの意思決定、仕様との整合確認

※ 本記事で「lint」と呼んでいるのは、狭義のlintルールだけでなく、Dart Analyzerによる型・null安全性チェックや、flutter_test・Golden Testといった自動テストも含めた総称です。いずれも「決定的に判定でき、実行結果を機械的に照合してCIでブロックできる」という条件を満たすため、便宜上まとめて扱います。

💡 見分け方のコツ

ある指摘について、「ルールとして定式化してCIに組み込めるか?」と自問してみてください。Yesならlint・静的解析・自動テストの担当です。Noであっても、「AIなら文脈から違和感の候補として拾えるか?」を次に考え、それも難しいものだけを最初から人間の判断に委ねる——という順番で考えると整理しやすくなります。

4. Flutterに学ぶ「機械に渡すべき指摘」の実例

実際にどこまで機械的な線引きができるのか、Flutter開発で使われるcustom_lintという仕組みを例に見ていきます。custom_lintは、Dart公式の標準lintでは対応できないプロジェクト固有のルールを追加できる仕組みで、AST(抽象構文木)やファイルパスをもとに違反を検出します。

以下はいずれも、custom_lintという仕組みの上にパッケージやプロジェクトが独自に実装したルールの例です(custom_lint自体に標準で組み込まれている検出機能ではありません)。AIに読ませて都度判断させるのではなく、CIで機械的にブロックできる指摘という点が共通しています。

レイヤー間の許可されていない依存を禁止する
custom_lint / import_rule

「presentation層からrepository層を直接importしない」といったアーキテクチャ上の制約は、import文を構文的にチェックするだけで機械的に検出できます。

analysis_options.yaml
custom_lint:
  rules:
    - import_rule:
      presentation_layer:
        target: "package:app/feature/*/presentation/*.dart"
        from: "package:app/feature/*/*.dart"
        message: "Presentation層では許可された依存関係のみ使用できます。"
特定フォルダに置けるファイルの命名を強制する
custom_lint(独自ルール)

「applicationフォルダには *_service.dart 以外のファイルを置かない」といったチーム独自の配置規約は、ファイルパスとファイル名を正規表現でチェックするだけで判定できます。

lintルール(抜粋)
class ApplicationLayerLintRule extends DartLintRule {
  @override
  void run(CustomLintResolver resolver, ErrorReporter reporter,
      CustomLintContext context) {
    final path = resolver.source.uri.path.split('/');
    final fileName = path.last.split('.').first;
    final folder = path[path.length - 2];

    if (folder == 'application' &&
        !RegExp(r'^.+_service$').hasMatch(fileName)) {
      reporter.atOffset(offset: 0, length: 1, errorCode: code);
    }
  }
}
UI再描画を目的とした参照で、ref.readとref.watchを取り違えているケース
custom_lint(Riverpod系)

build内でref.readを使うこと自体が常に誤りというわけではありません。ただし「画面を再描画したい値をref.readで取得している」場合は、値の変更を購読していないため、変更時に再ビルドされないという具体的な不具合につながります。この「UI更新目的の参照かどうか」を呼び出し位置とメソッド名の組み合わせから機械的に検出できます。

Bad
Widget build(BuildContext context, WidgetRef ref) {
  final counter = ref.read(counterProvider);
  // 値の変更を購読していないため、
  // 変更されても再ビルドされない
  return Text('$counter');
}
Good
Widget build(BuildContext context, WidgetRef ref) {
  final counter = ref.watch(counterProvider);
  // 値の変化を購読している
  return Text('$counter');
}
コントローラのdispose漏れ
custom_lint(独自ルール)

TextEditingControllerなどをdispose()し忘れるとメモリリークにつながります。フィールドで直接生成されているような単純なパターンであれば、生成箇所と解放箇所の対応関係を構文的に追跡し、「disposeされていない可能性」を検出できます。

Bad
class _MyWidgetState extends State<MyWidget> {
  final _controller = TextEditingController();
  // disposeされていない

  @override
  Widget build(BuildContext context) =>
      TextField(controller: _controller);
}
Good
class _MyWidgetState extends State<MyWidget> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>
      TextField(controller: _controller);
}

※ 別メソッドへの委譲(final controller = createController();のような間接生成)や継承・条件分岐が絡む複雑なケースでは、単純なAST(抽象構文木)パターンだけでは検出しきれない場合があります。「万能に検出できる」わけではなく、あくまで一定パターンに対する検出です。

文字列の直書き禁止(i18n強制)
custom_lint(独自ルール)

「Widgetのテキストに文字列リテラルを直接書かない」というi18nルールは、引数が文字列リテラルかどうかを構文的に判定するだけで検出できます。

Bad
Text('Hello, world!');
Good
Text(AppLocalizations.of(context).greeting);
テスト容易性のためのチーム独自規約(DateTime.now()の直接呼び出し禁止)
custom_lint(独自ルール)

「DateTime.now()を直接呼ばず、注入されたClockを経由する」というテスト容易性の規約も、特定メソッドの呼び出しパターンとして機械的に検出できます。「dispose漏れ」と同様、あくまで一定の呼び出しパターンに対する検出です。

Bad
class OrderService {
  bool isExpired(Order order) {
    // DateTime.now()を直接呼ぶとテストで
    // 時刻を固定できない
    return DateTime.now().isAfter(order.expiresAt);
  }
}
Good
class OrderService {
  OrderService(this._clock);
  final Clock _clock;

  bool isExpired(Order order) {
    return _clock.now().isAfter(order.expiresAt);
  }
}

ここまで紹介したルールを、実際に1つのプロジェクトのanalysis_options.yamlにまとめるとこうなります(プロジェクトが独自に実装するcustom_lintルールの例で、ルール名はイメージです)。

5つの観点をまとめたanalysis_options.yaml(イメージ)
custom_lint / 設定例
analysis_options.yaml
include: package:flutter_lints/flutter.yaml

analyzer:
  plugins:
    - custom_lint

custom_lint:
  rules:
    # 1. アーキテクチャ・レイヤー制約 --------------------------------------
    - layer_file_naming:
        directories:
          "data/services": ["_service.dart", "_client.dart"]
          "data/repositories": ["_repository.dart"]
          "data/models": ["_api_model.dart", "_model.dart"]
          "domain/use_cases": ["_use_case.dart"]
          "ui/features/*/view_models": ["_view_model.dart"]
          "ui/features/*/views": ["_view.dart", "_page.dart"]
    - layer_import_boundary:
        layers:
          domain: []
          data: [domain]
          ui: [domain, data]

    # 2. Riverpod の使い方 -----------------------------------------------
    - avoid_ref_read_inside_build
    - prefer_static_provider

    # 3. Widget 実装上の注意点 -----------------------------------------
    - always_dispose_controllers
    - avoid_single_child_flex

    # 4. i18n 強制 ----------------------------------------------------
    - no_literal_string_in_widget:
        allow_empty: true

    # 5. チーム独自規約 ------------------------------------------------
    - avoid_datetime_now

※ ここまでの本文では紹介しきれなかったprefer_static_provider(providerをトップレベルのfinal変数として定義することを強制するもの)やavoid_single_child_flex(単一子要素のColumn/Rowを検出するもの)も含めています。前者はRiverpod公式ドキュメントが推奨する書き方、後者は前回までの記事内でも触れたルールです。

この設定を検証するテストコードのイメージ(expect_lint)
custom_lint / テスト

custom_lintのテストでは、// expect_lint: ルール名というコメントを使い、「このルールがここで検出されるはずだ」という期待値をコード上に書く手法がよく使われます。ここでは同じサンプルアプリ(Counterアプリ)を題材に、上のYAMLで定義した各ルールが検出するNGパターンをまとめて示します。

layer_import_boundary:dataレイヤーからuiレイヤーの型に触れている

// expect_lint: layer_import_boundary
import 'package:sample_app/ui/features/counter/views/counter_page.dart';

class BadAnalyticsService {
  void track(Object screen) {
    // data から ui の型に触ってしまっている悪い例
    assert(screen is CounterPage || true);
  }
}

layer_file_naming:配置フォルダに対して許可されていない命名のファイル

// expect_lint: layer_file_naming
import 'package:sample_app/data/models/counter_api_model.dart';

class CounterCache {
  CounterApiModel? last;
}

layer_import_boundary:domainがdataの型(APIモデル)を直接知っている

// expect_lint: layer_import_boundary
import 'package:sample_app/data/models/counter_api_model.dart';

/// domain が data の型(API モデル)を直接知ってしまっている悪い例。
class BadCounterStats {
  const BadCounterStats(this.raw);

  final CounterApiModel raw;
}

avoid_datetime_now:DateTime.now()の直接呼び出し

void increment() {
  // NG: 現在時刻を直接取得している。テストで時刻を固定できない。
  // expect_lint: avoid_datetime_now
  final now = DateTime.now();
  final next = Counter(value: state, updatedAt: now).increment(now);
  state = next.value;
}

1つのWidgetに複数のNGパターンが同時に現れる例

class BadCounterView extends ConsumerWidget {
  const BadCounterView({super.key, required this.provider});

  // NG: providerをフィールドで持ち回している。静的解析できずwatchも追えない。
  // expect_lint: prefer_static_provider
  final NotifierProvider<CounterViewModel, int> provider;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // NG: build直下のref.read。値が変わっても再描画されない。
    // expect_lint: avoid_ref_read_inside_build
    final count = ref.read(counterViewModelProvider);

    // NG: 子が1つしかないColumn。
    // expect_lint: avoid_single_child_flex
    return Column(
      children: [
        // NG: Textへの文字列直書き(i18nされていない)。
        // expect_lint: no_literal_string_in_widget
        Text('現在の値: $count'),
      ],
    );
  }
}

always_dispose_controllers:Controllerのdispose漏れ

class BadInputField extends StatefulWidget {
  const BadInputField({super.key});

  @override
  State<BadInputField> createState() => _BadInputFieldState();
}

class _BadInputFieldState extends State<BadInputField> {
  // expect_lint: always_dispose_controllers
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}

※ 実際のテストでは、これらのNG例に対応する形でOK例(ルール違反を修正したコード)も用意し、両方に対してcustom_lintのテストランナーを実行して検証します。

lintだけではない:Dart Analyzer・自動テストとの関係
Dart Analyzer / flutter_test / Golden Test

ここまで見てきたcustom_lintは、Flutter/Dartで「実行せずに機械的に判定できる」仕組みの一部です。Dart Analyzer・flutter_test・Golden Testも性質は違いますが、同じ「決定的・低コスト・CIでブロック可能」という条件を満たします。

Dart Analyzer:コードを実行せず、型やnull安全性を検査する

「Stringを渡すべき引数にintを渡している」「nullかもしれない値をnullチェックなしで使っている」といった違反は、custom_lintの土台でもあるDart Analyzerが標準で検出します。custom_lintは、このAnalyzerのプラグイン機構の上で動く拡張です。

flutter_test:実際にWidgetを動かして振る舞いを検証する

counter_test.dart
testWidgets('ボタンをタップするとカウントが増える', (tester) async {
  await tester.pumpWidget(const CounterApp());

  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});

「このボタンを押したら、この状態になるはずだ」という期待値をコードで書いておき、実行結果と機械的に照合します。lintがコードの構造を検査するのに対し、flutter_testは実際に動かした振る舞いを検査します。

Golden Test:見た目の意図しない変化を検出する

Widgetを実際に描画し、あらかじめ保存しておいた基準画像(golden image)とピクセル単位で比較します。「ロジックは正しいが、意図せずレイアウトが崩れた」という、AIにも人間のレビュアーにも気づきにくい変化を機械的に検出できます。

これらはいずれも、AIに読ませて都度判断させる必要のない指摘です。lint・静的解析・自動テストに任せることで、機械的に判定できる指摘をレビューコメントから切り離し、人間が確認すべきレビューに時間を集中できます。もちろんこれらの結果確認やルール・テストのメンテナンス、誤検知への対応といったコストはゼロにはなりませんが、それでも「都度AIやレビュアーに読ませて判断させる」コストよりはるかに小さく抑えられます。

5. AIレビューに残すべき仕事

ここまでの話は、「AIレビューを導入するかどうか」ではなく、レビュー工程そのものを3段階に分解して設計するという話に置き換えられます。

STEP 1・lint / 静的解析 / 自動テスト
ルール違反・振る舞いの異常を機械的に検出する

AST・型情報・命名パターンなど構文的に決定的に判定できる違反や、flutter_test・Golden Testの実行結果との差分をCIでブロックする。

STEP 2・AIレビュー
違和感・不整合の候補を提示する

大量のコードを文脈付きで横断的に読み、人間が見落としそうな問題の候補を挙げる。

STEP 3・人間レビュー
最終的な要否を判断する

lint・AIが挙げた候補が実際に対応すべき問題かどうかを判断し、合意形成する。

STEP2のAIレビューが担うべきなのは、「lintでは判定できないが、人間が一人でPRを読んでいるだけでは気づきにくい」種類の問題です。コードベース全体の情報をコンテキストとして与えられる場合、1件のPRだけを見ていては分かりにくい関係性まで横断的に確認できることがAIの強みです(これはAIレビューの仕組みやコンテキスト設定次第であり、常に自動でコードベース全体を見渡せるわけではありません)。例えば、次のような候補出しが挙げられます。

この実装は、既存の設計方針と不整合を起こしていないか
類似の処理が他の箇所にもあるが、実装の仕方に差異が生まれていないか
API仕様と実装の間にズレが生まれていないか
想定されるエラーケースのうち、ハンドリングが抜けているものはないか
既存コードの書き方・命名との一貫性が保たれているか
この変更に対して、不足していそうなテストケースはないか

lintは「ルール違反を探す」、AIは「違和感・不整合の候補を探す」、人間は「それを問題として扱うか決める」。

これらの候補出しは、ルールとして定式化してCIに組み込むことが難しく、lintでは判定できません。かといって、最終的に「対応するかどうか」を決めるのは人間の役割であり、AIが挙げた候補をそのまま鵜呑みにする必要もありません。AIレビューの価値は「正解を出すこと」ではなく、「見落としの候補を増やすこと」にあります。

📌 実務での切り分け方

まずプロジェクトにcustom_lint(や同種の静的解析)・flutter_test・Golden Testで拾えるルールとテストを一通り実装し、CIでブロックします。AIレビューには、機械的なチェックを解消した後のコードを渡し、プロンプトも「既存コードとの整合性・不整合の候補出し」に絞って依頼します。こうすることで、AIの指摘密度が下がり、人間レビュアーが読むべきコメントの質が上がります。

6. まとめ:レビューの役割分担を設計する

AIコードレビューを導入する際に問うべきなのは、「AIに何を聞くか」を先に設計したかどうかです。

観点確認すべきこと
lint・静的解析・自動テストで拾えるものの棚卸し 命名規則・import制約・dispose漏れ・型やnull安全性、Widgetの振る舞いや見た目の回帰など、custom_lint・Dart Analyzer・flutter_test・Golden Testで検出できる指摘を洗い出したか
AIに渡す範囲の限定 機械的なルール違反を解消した後のコードをAIレビューに渡す設計になっているか
プロンプトの焦点 AIへの指示が「機械的な指摘」ではなく「設計意図・保守性・要求充足」に絞られているか
コスト対効果の検証 AIレビューのトークンコストと、削減できた人間レビュー時間を比較しているか
役割分担の見直し lint・AI・人間レビューの三者の役割を、定期的に棚卸しして更新しているか

AIコードレビューの価値は「なんでも読ませること」ではなく、「機械に任せられるものを機械に任せ、人と機械にしか判断できないものにAIと人間のレビュー工数を集中させること」で最大化されます。Flutterのcustom_lintはその線引きを考えるための具体的な材料の一つに過ぎませんが、同じ発想は言語やフレームワークを問わず応用できます。

FREE AIコードレビュー導入のご相談

「AIに何を聞くか」、設計できていますか?

AIレビューがかえってノイズ源になっているとしたら、その原因は性能ではなく「lint・AI・人間」の役割分担かもしれません。まずは今のレビュー工程を、誰が・何を見ているかで一緒に整理してみませんか。

lintで拾えるはずの指摘が、AIレビューに混ざっていないか
AIに渡すプロンプトの範囲が、判断が必要な指摘に絞られているか
lint・AI・人間、それぞれの役割が定義されているか
AI駆動開発に課題がある方はこちら →

「AIレビューを入れたはずなのに、レビューが楽になっていない」という段階でのご相談も歓迎しています。