Flutter / custom_lintに学ぶ、静的解析とAIの役割分担
「コードレビューをAIに任せれば、レビュー負荷が減るはず」——そう考えて導入したものの、実際にはPRごとに大量のコメントが付き、それを一つずつ確認する時間の方が長くなった、という声を聞くことが増えました。
原因の多くは、AIの性能ではありません。「機械的に判定できる指摘」と「人間の判断が必要な指摘」を切り分けずに、すべてをAIに任せてしまっていることにあります。この記事では、Flutter開発で使われる静的解析の仕組み(custom_lint)を題材に、AIレビューに何を任せ、何を任せるべきでないかを整理します。
AIコードレビューがかえってボトルネックになるメカニズム、「機械的に検出できる指摘」を見分ける軸、そしてFlutter・custom_lintを題材にした具体的な線引きの実例がわかります。
例えば、こんな場面を想定してみてください。AIコードレビューを導入したチームで、あるPRに数十件のコメントが自動で付いたとします。その大半は「利用用途が分かる変数名にすべき」「このWidgetは分割できる」といった一般論で、レビュアーはコメントを一つずつ読んで、対応が必須のものと、任意のものを仕分ける作業に時間を取られる——このように、レビュー全体のリードタイムが導入前より伸びてしまうケースは起こりえます。
この状況には、二つの問題が重なっています。一つは、AIレビューのために消費されるトークンと実行時間のコスト。もう一つは、大量の指摘によって人間レビュアーの負荷がむしろ増える、という逆説的な結果です。
なぜ多くのチームが、機械的に判定できることまでAIに聞いてしまうのでしょうか。背景には、いくつかの共通した思考パターンがあります。
結果として、本来ならlintやCIで機械的にブロックできる指摘までAIの応答に混ざり込みます。AIを呼び出すコストと、その出力を人間が読むコストの両方が、静かに積み上がっていくのです。
「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なら文脈から違和感の候補として拾えるか?」を次に考え、それも難しいものだけを最初から人間の判断に委ねる——という順番で考えると整理しやすくなります。
実際にどこまで機械的な線引きができるのか、Flutter開発で使われるcustom_lintという仕組みを例に見ていきます。custom_lintは、Dart公式の標準lintでは対応できないプロジェクト固有のルールを追加できる仕組みで、AST(抽象構文木)やファイルパスをもとに違反を検出します。
以下はいずれも、custom_lintという仕組みの上にパッケージやプロジェクトが独自に実装したルールの例です(custom_lint自体に標準で組み込まれている検出機能ではありません)。AIに読ませて都度判断させるのではなく、CIで機械的にブロックできる指摘という点が共通しています。
「presentation層からrepository層を直接importしない」といったアーキテクチャ上の制約は、import文を構文的にチェックするだけで機械的に検出できます。
custom_lint:
rules:
- import_rule:
presentation_layer:
target: "package:app/feature/*/presentation/*.dart"
from: "package:app/feature/*/*.dart"
message: "Presentation層では許可された依存関係のみ使用できます。"
「applicationフォルダには *_service.dart 以外のファイルを置かない」といったチーム独自の配置規約は、ファイルパスとファイル名を正規表現でチェックするだけで判定できます。
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);
}
}
}
build内でref.readを使うこと自体が常に誤りというわけではありません。ただし「画面を再描画したい値をref.readで取得している」場合は、値の変更を購読していないため、変更時に再ビルドされないという具体的な不具合につながります。この「UI更新目的の参照かどうか」を呼び出し位置とメソッド名の組み合わせから機械的に検出できます。
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.read(counterProvider);
// 値の変更を購読していないため、
// 変更されても再ビルドされない
return Text('$counter');
}
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
// 値の変化を購読している
return Text('$counter');
}
TextEditingControllerなどをdispose()し忘れるとメモリリークにつながります。フィールドで直接生成されているような単純なパターンであれば、生成箇所と解放箇所の対応関係を構文的に追跡し、「disposeされていない可能性」を検出できます。
class _MyWidgetState extends State<MyWidget> {
final _controller = TextEditingController();
// disposeされていない
@override
Widget build(BuildContext context) =>
TextField(controller: _controller);
}
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(抽象構文木)パターンだけでは検出しきれない場合があります。「万能に検出できる」わけではなく、あくまで一定パターンに対する検出です。
「Widgetのテキストに文字列リテラルを直接書かない」というi18nルールは、引数が文字列リテラルかどうかを構文的に判定するだけで検出できます。
Text('Hello, world!');
Text(AppLocalizations.of(context).greeting);
「DateTime.now()を直接呼ばず、注入されたClockを経由する」というテスト容易性の規約も、特定メソッドの呼び出しパターンとして機械的に検出できます。「dispose漏れ」と同様、あくまで一定の呼び出しパターンに対する検出です。
class OrderService {
bool isExpired(Order order) {
// DateTime.now()を直接呼ぶとテストで
// 時刻を固定できない
return DateTime.now().isAfter(order.expiresAt);
}
}
class OrderService {
OrderService(this._clock);
final Clock _clock;
bool isExpired(Order order) {
return _clock.now().isAfter(order.expiresAt);
}
}
ここまで紹介したルールを、実際に1つのプロジェクトのanalysis_options.yamlにまとめるとこうなります(プロジェクトが独自に実装するcustom_lintルールの例で、ルール名はイメージです)。
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公式ドキュメントが推奨する書き方、後者は前回までの記事内でも触れたルールです。
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のテストランナーを実行して検証します。
ここまで見てきた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を動かして振る舞いを検証する
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やレビュアーに読ませて判断させる」コストよりはるかに小さく抑えられます。
ここまでの話は、「AIレビューを導入するかどうか」ではなく、レビュー工程そのものを3段階に分解して設計するという話に置き換えられます。
AST・型情報・命名パターンなど構文的に決定的に判定できる違反や、flutter_test・Golden Testの実行結果との差分をCIでブロックする。
大量のコードを文脈付きで横断的に読み、人間が見落としそうな問題の候補を挙げる。
lint・AIが挙げた候補が実際に対応すべき問題かどうかを判断し、合意形成する。
STEP2のAIレビューが担うべきなのは、「lintでは判定できないが、人間が一人でPRを読んでいるだけでは気づきにくい」種類の問題です。コードベース全体の情報をコンテキストとして与えられる場合、1件のPRだけを見ていては分かりにくい関係性まで横断的に確認できることがAIの強みです(これはAIレビューの仕組みやコンテキスト設定次第であり、常に自動でコードベース全体を見渡せるわけではありません)。例えば、次のような候補出しが挙げられます。
これらの候補出しは、ルールとして定式化してCIに組み込むことが難しく、lintでは判定できません。かといって、最終的に「対応するかどうか」を決めるのは人間の役割であり、AIが挙げた候補をそのまま鵜呑みにする必要もありません。AIレビューの価値は「正解を出すこと」ではなく、「見落としの候補を増やすこと」にあります。
まずプロジェクトにcustom_lint(や同種の静的解析)・flutter_test・Golden Testで拾えるルールとテストを一通り実装し、CIでブロックします。AIレビューには、機械的なチェックを解消した後のコードを渡し、プロンプトも「既存コードとの整合性・不整合の候補出し」に絞って依頼します。こうすることで、AIの指摘密度が下がり、人間レビュアーが読むべきコメントの質が上がります。
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はその線引きを考えるための具体的な材料の一つに過ぎませんが、同じ発想は言語やフレームワークを問わず応用できます。
AIレビューがかえってノイズ源になっているとしたら、その原因は性能ではなく「lint・AI・人間」の役割分担かもしれません。まずは今のレビュー工程を、誰が・何を見ているかで一緒に整理してみませんか。
「AIレビューを入れたはずなのに、レビューが楽になっていない」という段階でのご相談も歓迎しています。