AI に「禁止」と書くより、lint とテストで落ちる仕組みにする

テストClaudeCodeESLint

CLAUDE.md や AGENTS.md に「〇〇しないで」「〇〇は禁止」を書き足し続けて、AI と喧嘩していませんか。この記事の主張は 1 つだけです。AI に「守って」と頼むより、破ったら lint やテストで落ちる仕組みにした方が、AI は素直に言うことを聞いてくれます。

「禁止」を書いても守られない理由

コーディングエージェントに実装を任せていると、こういうことが起きます。

  1. 画面から fetch を直接呼んだコードが上がってくる
  2. 「通信は lib/api/client を通して」とレビューで直す
  3. CLAUDE.md に「画面で fetch を直接呼ばない」と書き足す
  4. 1 週間後、別の画面でまた fetch を直接呼んだコードが上がってくる
  5. CLAUDE.md に「絶対に」を足す

これを繰り返すと CLAUDE.md が禁止事項の山になります。私が運用している業務システムの CLAUDE.md は、170 行の中に「しない」「禁止」「使わない」が 32 回出てきます。

守られない理由は AI の怠慢ではなく、構造的なものです。

  • AI は毎回コンテキストを読み直す。文書が長くなるほど、1 つ 1 つのルールの重みは薄まる
  • ルールは「いつ適用されるか」を AI が自分で判断しなければならない。「通信は共通処理を通す」と書いてあっても、目の前のコードがその「通信」に当たるかは、AI が推論して初めて分かる
  • 人間のレビューは実装の後に来る。AI はその時点で「完成した」と思っている

つまり、文書のルールは「覚えておいて、該当するときに思い出して、自分で適用して」という 3 段階の頼み事です。人にとっても難しい頼み方を、AI にしているわけです。

「落ちる」ルールは 1 段階で済む

同じルールを ESLint に移すとこうなります。

eslint.config.mjs
'no-restricted-syntax': [
  'error',
  {
    selector: "CallExpression[callee.name='fetch']",
    message: '通信は lib/api/client(getJson / postJson / putJson / patchJson / deleteJson)を通す',
  },
],

AI が fetch を書くと、lint が落ちて、このメッセージが返ります。AI はメッセージを読んで postJson に書き換えます。「覚える・思い出す・適用する」の 3 段階が消えて、「落ちたら直す」の 1 段階になります。

ここで大事なのは、エラーメッセージに「代わりに何を使うか」まで書くことです。「fetch は禁止」だけだと AI は別の抜け道(XMLHttpRequest や axios の追加)を探します。「lib/api/client の postJson を使う」と書いてあれば、次の試行でそこに向かいます。lint のメッセージは、AI にとっては最も確実に届くプロンプトです。

実際に lint に移したルールの例です。

CLAUDE.md に書いていたこと 移した先
画面で fetch を直接呼ばない no-restricted-syntax
alert を使わない no-restricted-globals
date-fns の format を使わない(TZ 事故) no-restricted-imports(importNames で関数単位)
route で request.json() を直接呼ばない no-restricted-syntax
業務ルール層から SDK を直接 import しない no-restricted-imports(ディレクトリ単位)
色コードを文字列に書かない no-restricted-syntax(正規表現で #[0-9a-f]{6})
テストは元ファイルの隣に置く 配置を検査する小さなスクリプト

移した後、CLAUDE.md 側の記述は「fetch の直呼びは ESLint で禁止」の一言に縮めます。文書が短くなるので、残った(機械化できない)ルールの重みが上がります。

lint にできないルールはテストと DB 制約に

「〇〇するときは△△も更新する」のような手順のルールは lint では書けません。これはテストにします。

  • 「API の応答の形を変えたら画面も直す」→ 応答の型を 1 か所で定義し、サーバーと画面が同じ型を import する。ずれたら型エラーで落ちる
  • 「この値の組み合わせは許されない」→ DB の CHECK 制約。アプリ側の検証とは別に、DB が最後の砦になる
  • 「マイグレーションで古いコードを壊さない」→ CI で全マイグレーションを素の DB に当てて検証する

どれも「気をつけて」を「落ちる」に変換しています。

既存のコードが多すぎて一度に直せないとき

ルールを lint にした瞬間、既存の違反が 100 件出て CI が真っ赤になる、というのはよくあります。ここで「新規だけ守る」と文書に書くと、また AI との喧嘩に戻ります。

代わりにラチェットを入れます。現在の違反件数を基準値として記録し、「増えたら落ちる・減らしたら基準値を下げる」だけの小さなスクリプトです。

eslint.boundary-ratchet.mjs
// 基準値。減らした PR は --update で下げる。増やすときだけ PR に理由を書いて手で直す
export const BOUNDARY_RATCHET = {
  componentsCallingApiClient: 22,
  componentsOverLineLimit: 12,
};
scripts/check-boundary-ratchet.mjs(要点)
const count = violations.length;
if (count > baseline) {
  console.error(`${key}: ${count} 件(基準 ${baseline})増加(NG)`);
  process.exit(1);
}
if (count < baseline && isUpdate) writeBaseline(key, count);

これで AI は既存の違反を増やせなくなります。減らすのは機能追加の合間に少しずつで構いません。私のプロジェクトでは、この仕組みを入れてから 1 か月で 22 件と 12 件がどちらも 0 件になりました。AI に「減らして」と頼んだのではなく、「触った画面のついでに、この数字を 1 つ減らして」と頼めるようになったのが大きいです。

事故が起きたときの閉じ方

不具合やレビュー指摘が出たとき、CLAUDE.md に「〇〇に気をつける」と書いて終わりにしたくなります。これをやめて、閉じる前に必ず次のどれかに変換する、と決めました。

  1. lint ルール(直呼び・命名・禁止 API)
  2. 型(応答の形・DTO)
  3. DB 制約(値の組み合わせ・一意性)
  4. ラチェット(既存分が多くて一度に直せないもの)

どれにも変換できないものだけを文書に残します。そして同じ指摘が 2 回来たら、個別に直す前に「原因が共通していないか」を疑って設計から見直します。

「なぜ」だけは文書に残す

lint に移すと「何が禁止か」は機械が覚えますが、「なぜそう決めたか」はどこにも残りません。ここは文書の出番で、ADR(Architecture Decision Record)に 1 判断 1 ファイルで残しています。

ADR には「採らなかった案」も書きます。「クリーンアーキテクチャ的な多層は、この規模では形骸化するので採らない」と書いてあれば、AI が次の実装で usecase 層を勝手に足すことがなくなります。禁止事項を並べるより、「なぜ今の形なのか」を 1 つ読ませる方が、AI の判断は安定します。

まとめ

  • 文書の「禁止」は「覚える・思い出す・適用する」の 3 段階の頼み事。AI には届きにくい
  • lint・型・テスト・DB 制約に移すと「落ちたら直す」の 1 段階になる
  • エラーメッセージには「代わりに何を使うか」を書く。それが AI に一番確実に届くプロンプト
  • 既存の違反が多いときはラチェットで凍結し、増やせなくしてから減らす
  • 事故は「気をつける」で閉じず、構造に変換してから閉じる
  • 「なぜ」だけは ADR に残す

CLAUDE.md が禁止事項で膨らんできたら、それは AI が悪いのではなく、ルールの置き場所が違うサインです。

← ブログ一覧へ戻る