綴Photo の勉強ノートに戻る
開発記録2026.07.28

いちばん強く書いていたルールが、唯一チェックされていなくて、すでに破られていた

アーキテクチャテストAI協働綴Photo

AIレビューの指摘の中で、コード自体は健全なのに直す価値があった、という記録です。

このプロジェクトには、ファイル同士の依存の向きについてルールがあります。「純粋な層は Flutter を知らない」「画面から DB を直接触らない」といったやつ。

守り方は、テストで見張る形にしてあります。

dart
// ルール名 → そのルールに違反している既知のファイル一覧
// 中身が空=今は既知の違反ゼロ
const Map<String, Set<String>> importAllowlist = {
  'domain-purity': {},          // 純粋な層が Flutter や DB を知らない
  'presentation-db': {},        // 画面から DB を直接触らない
  'core-to-features': {},       // 共通の土台から個別機能を見にいかない
  'cross-feature-internal': {}, // 他の機能の中身を直接指さない(窓口ファイル経由だけ可)
};

lib/ の全ファイルを走査して import を集め、ルールに違反していたらテストが落ちます。既知の違反は上のリストに載せておきます。違反は増やせないし、減った分は元に戻せません。数は減る方向にしか動きません=一方向のラチェット(逆回しができない歯車)。

この仕組みを入れたときの話は「既存の違反は責めず、新しい違反だけ止める — アーキテクチャ検査を一方向ラチェットにする」に書いた。増やさない側と戻せない側をどう作ったかはそっち。上のリストが全部空なのは、そのとき34件あった既知の違反が、その後ゼロになったから。

これで守っているルールは4本。ところが、ルールは全部で5本あります。

5本目だけ、テストの外にあった

5本目はこれ。AI への指示書(CLAUDE.md)にこう書いてあります。

ルーター (app/router/) は screens/ しか import しない。widgets/ を直接ルートに指すのはレイヤ違反。

太字で、断定で、いちばん強い言い方をしています。人間にも AI にも、これがルールだと分かるように書いた。

ルールは5本。検査に載っているのは左の4本だけ。検査(CI)test/architecture/import_allowlist.dartdomain-puritypresentation-dbcore-to-featurescross-feature-internal定数はコード。import 行を読む指示書だけCLAUDE.mdrouter-scopescreens/ しか import しない検査なし太字の断定。機械は見ないルール文はファイルにあり、実行時の DB にもメモリにも無い。

後から見ると、形が違っていた。他の4本は「これを import してはいけない」という禁止の形をしている。禁止先を1つ挙げれば検査になる。5本目だけが「これしか import しない」という許可の形で、許していい先を全部決めてからでないと1行も書けない。

チェックを書いたら、知らない違反が2件出た

5本目として router-scope を足した。ルーターの中から features/ を指す import を拾って、presentation/screens/ 以外なら違反、という形にした。

走らせたら落ちた。落ちたときテストが出したメッセージがこれ。// の右が、実際に指していた import 先。

新しい依存ルール違反があります。直すのが原則。
  'router-scope': {
    'app/router/app_router.dart', // ../../features/onboarding/application/providers/onboarding_provider.dart
  },

1件目。ルーターがオンボーディングの provider(「オンボーディング済みか」の状態を持って配る係)を読んでいた。「オンボーディング済みかどうか」で最初の行き先を決めるため。

これはレビューの指摘に挙がっていたので想定内だった。想定外だったのは、その1件を許した後にもう1回落ちたこと。

  'router-scope': {
    'app/router/route_payloads.dart', // ../../features/tsuzuru/domain/models/tsuzuru_args.dart
  },

2件目。ルートに渡す引数の型が、機能側の純粋な層に置いてあった。

レビューが挙げていたのは1件だけで、2件目は検査を書いて走らせるまで誰も知らなかった。自分も知らなかった。

コードは健全で、ルール文のほうが間違っていた

2件とも、コードとしては妥当だった。

  • ルーターは composition root(アプリ全体を組み立てる唯一の場所)なので、行き先を決めるために状態を読むのは役割どおり
  • ルートの引数に機能側の型を使うのも自然。ルートの語彙そのものなので

しかも、ルールの正本ドキュメントは一つの文書の中で矛盾していた。上のほうに依存の図があって、そこには「app(router/di)は全層を組み立ててよい(唯一の composition root)」と書いてあります。その数行下に「router は screens/ だけ」と書いてあった。

打ち手は3つあった。

打ち手 効くこと 払うもの
コードを直す(ルーターから provider 参照を外す) ルール文をそのまま守れる 行き先の判定を別の場所へ追い出すことになる。composition root の役割としては不自然
許可リストに2件載せる その場は緑。作業が止まらない 「既知の違反」が居座る。ルール文が実態と食い違ったまま固定される
ルール文の線を引き直す 正本とコードが一致する。今後の判定が効く 線をどこに引くか自分で決める必要がある。決め方を間違うと検査ごと嘘になる

なので、直したのはコードではなくルール文のほう。

線をこう引き直した。

完了フラグは prefs の onboarding_complete。型はコード。写真の行は data が端末 SQLite から取る。app/router/composition root行き先の対応表コード。保存しない可presentation/screens入口。画面そのもの可application/providers行き先の状態。prefs の onboarding_complete可domain型。コードにあり、保存しない不可presentation/widgetsどう描くか。部品。見ない不可dataどう取ってくるか。端末 SQLite・サーバ
dart
if (file.startsWith('app/router/') &&        // ルーターの中のファイルで
    resolved != null &&
    resolved.startsWith('features/')) {      // 機能側を指す import なら
  final allowed = RegExp(
    // 許す行き先を並べたリスト。[^/]+ の部分は「機能名は何でもいい」の意味
    r'^features/[^/]+/(presentation/screens|application/providers|domain)/',
  ).hasMatch(resolved);
  if (!allowed) {
    yield Violation('router-scope', file, uri);   // 違反として報告=テストが落ちる
  }
}

許すものを数える書き方(肯定リスト)にしてあります。「これはダメ」を並べる形にすると、機能側に新しい層が生まれた日に素通しします。

ルールの正本と CLAUDE.md の両方を同時に直した。片方だけ直すと、また食い違いが残ります。

チェックが「落ちるべき時に落ちるか」を確かめた

新しい検査は、通ることを確かめただけでは意味がありません。守りたい形を実際に壊して、落ちるかを見た。

ルーターにわざと部品の import を1行足して走らせたら、ちゃんと落ちた。確認したら消した。

これをやる理由は前にも痛い目を見ていて、CI に入れた別の検査が、入れた直後から一度も機能していなかったことがある(git diff で判定していたが、守りたい変化が「ファイルの追加」だったので見えていなかった)。導入の次にスキーマを変えた時から、15日間ずっと緑のままだった。緑は「通った」の意味かもしれませんし「届いていない」の意味かもしれません。

なぜ急いだか

次にルーターを触るのが、アプリのリンク方式の移行(App Links)だと分かっているから。

外部から特定の画面へ飛ばす仕組みを入れる作業なので、ルーターを大きく書き換える。その時に部品を直接ルートに指しても、検査が無ければ緑のまま通ってしまいます。触る直前に閉じておきたかった。

学び

  • ルールを検査に載せる作業自体が、実態調査になる。載せる前に「実態は把握している」と思わないほうがいいです。今回は把握できていなかった
  • 検査が落ちたら、まずコードとルール文のどちらが間違っているかを見る。今回はコードが正しくてルール文が間違っていた。ルール文が正本の中で自己矛盾していたのも、載せるまで気づかなかった
  • 肯定リストは、正当な新しい層が生まれた日にも落ちる。それは失敗ではなく、線を引き直す判断をそのたび迫る仕組みのほう

指示書でいちばん強く書いた1本が、いちばん長く実態と離れていた。書き方の強さは、実態を把握していることを意味しません。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。