AIレビューで出た指摘を追いかけたら、自分のコードのコメントが事実と逆のことを書いていた、という記録です。直したあとの「どこに歯止めを置くか」のほうが長くなったので、そっちも書きます。
扱っているのは、綴った日記の本文と、写真のファイルパスだ。画面に出す文言は丸めてある(=詳細を出さず「保存できませんでした」程度に留める)し、パスをログに出すときは末尾のファイル名だけに切り詰める関数まで用意してあった。
それでも漏れていた。裏口が1つ開いていた。
名前が嘘をついていた
問題の行はこれ。保存に失敗したときの catch の中にある。
} catch (e, st) { // e = 例外そのもの / st = スタックトレース(どこを通って失敗したかの経路)
debugPrint('[AddMeaningRepositoryImpl] saveNewMemory error: $e\n$st');
throw const MemoryPersistenceError();
}そして別のファイルには、こう書いてあった。
/// 読み込みエラーの詳細を debugPrint へ残す。
/// release ビルドでは debugPrint が無効化されるので、ユーザーには丸めた文言のみが見える。Flutter SDK の該当箇所を開くと、こう書いてある。
The [debugPrint] function logs to console even in release mode (debugPrint はリリースモードでもコンソールへ出力する)
逆だった。
名前と用途が開発向けなので、リリース版では消えるものだという前提で書かれていた。
何が流れていたか
$e の中身が問題だった。端末の中にデータを保存する仕組み(データベース)には Drift というライブラリを使っていますが、その配下の SQLite ライブラリの例外は、失敗した SQL 文と、そこに渡した値を文字列にします。
パスには、本名を入れている人も多い OS のユーザー名とフォルダの並びが入ります。失敗する条件は、ディスクの空き不足・制約違反・DBのロックあたりで、珍しくはありますが起こります。
しかも上のコメントが逆のことを主張しているので、次に何かをログへ足す人も、同じ前提で足します。
直し方を1つ変えた
素直な直し方は、出力している場所を全部 if (kDebugMode) で囲むことです。これで漏れは止まります。
引っかかったのは、囲み忘れたことを後からどう見つけるか。
「debugPrint の呼び出しが if の中にあるか」を機械で判定しようとすると、コードの構文解析が要ります。7箇所くらいなら目で見ればいい、で済ませられるが、それは今日の自分が7箇所を覚えている間だけ有効な話で、明日新しく足す1箇所は守れません。
なので検査を賢くするのをやめて、検査しやすい形にコードを変えた。 debugPrint を呼んでいいファイルを1つに決める。
// lib/core/diagnostics/debug_log.dart
// lib/ から debugPrint を呼ぶ唯一の窓口。
void debugLog(String Function() message) { // ← 文字列でなく「文字列を作る手順」を受け取る
if (kDebugMode) { // ← 定数なので release ではこのブロックごと消える
debugPrint(message()); // ← ここで初めて手順が実行されて文字列になる
}
}こうすると検査は「lib/ の中で debugPrint( と書いてある行を数える。1ファイル以外にあったら落とす」で済みます。構文解析が要りません。
// test/architecture/debug_log_rule_test.dart(抜粋)
// アプリは動かさない。lib/ の .dart ファイルを1行ずつ「文字」として読むだけのテスト。
if (line.trimLeft().startsWith('//')) continue; // 行頭が // =コメント行なので飛ばす
if (!line.contains('debugPrint(')) continue; // debugPrint( を含まない行も飛ばす
offenders.add('$path:${i + 1} ${line.trim()}'); // 残った=違反。ファイル名:行番号で記録移す前にこのテストを流して、7箇所ちょうどが行番号付きで並ぶことを先に確かめた。通ることだけ確かめたテストは、落ちるべき形を一度も試していませんので。
引数を文字列にしなかった理由
debugLog('...$e') と書けるほうが読みやすいです。実際そうしかけて、やめた。
debugLog('error: $e'); // ← release でも e.toString() が走る
debugLog(() => 'error: $e'); // ← release では中身ごと実行されない出力はされないので漏れはしません。ただ、今回消したかったのは作ることのほうだった。
呼び出しに () => が付くぶん少し騒がしいですが、こちらを選んだ。
書いてあった対策を1つ外した
AIレビューから起こした実装の設計書には、もう1つ指示があった。「例外の型と場所だけを出して、例外そのものとスタックトレースは出さない」。中身を削るほうの対策。
これは採らなかった。
リリース版で出力ごと消える以上、残るのは開発中の自分のログだけです。そこで詳細を削ると、保存の失敗を調べるときだけが不便になります。詳細が要るのは、まさにその瞬間なので。
下の層が効いたら、上の層がまだ要るかを問い直します。両方を惰性で入れると、守るものは増えないのに使い勝手だけ削れます。
ただし、外したことは理由付きで書き残した。書かないと、次に読む人には「やり忘れ」と区別がつきません。
一方でパスを末尾だけに切り詰める処理は残した。こちらは「開発機のログにも要らないものは載せない」という別の方針なので、リリースの話が片付いても生きています。
「違反ゼロ」は、窓口ごと消えても緑になる
テストを書き終えてから気づいたことがある。
「debugPrint を直接呼んでいる場所はゼロ」というテストは、窓口のファイルごと誰かが消しても緑のままになります。呼び出しがゼロなのは変わりませんので。
なので、窓口が存在すること自体を別のテストで固定した。
const _allowedFile = 'lib/core/diagnostics/debug_log.dart'; // 許可された唯一のファイルパスを定数にする
test('唯一の窓口である debug_log.dart が実在する', () {
// expect(調べたい値, 期待する結果) の形で書く
expect(File(_allowedFile).existsSync(), isTrue); // 窓口ファイルの存在自体を固定する
});条件を「無いこと」で書いた検査は、前提ごと消えると自動的に満たされてしまいます。「◯◯が無いこと」を守るテストは、◯◯を作る前提のほうが消えたときも緑になります。
学び
- 関数名が挙動の説明になっていると思って、原文を読まずに設計の前提に置いた。原文は1行だった。名前は仕様ではありません
- コメントは検査されません。コードの嘘は動かせばバレますが、コメントは間違ったまま何年でも残り、しかも次の人の判断の入力になります
- 「機械検査するには賢い解析が要る」と思ったら、検査でなく対象の形を疑います。窓口を1つにすれば、検査は行を数えるところまで落ちます。守りたい規律に対して検査が高くつくときは、たいていコードのほうが検査しにくい形をしています
この検査が見ていなかった出口から同じ本文が出ていた話を、後日「1箇所に閉じ込めて検査まで置いたのに、裏口がもう1つ開いていた」に書いた。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。