はじめに
AI が書いたコードの品質は、プロンプトの工夫よりも、AI の外側に用意した「仕組み」で決まります。この記事では、Next.js(App Router)と TypeScript のフロントエンド開発を例に、AI 生成コードを本番品質に保つための仕組みを 4 つの層に分けて紹介します。
AI は「書かれていないこと」を推測で埋める
たとえば AI に「プロフィール編集フォームを作って」と頼んだとします。仕様書にニックネームの最大文字数やエラー文言が書かれていなければ、AI はそれらしい値を自分で決めて実装します。
企画側の想定が 20 文字でも、AI は 50 文字で実装するかもしれません。この種のズレはレビューでは見落とされやすく、気づくのは QA の段階か、リリース後になりがちです。
修正自体は AI を使えば数分で終わります。それでも、修正 PR の作成・レビュー・再テストは、本来なら発生しなかった工数です。こうした小さなズレが画面ごとに積み重なると、チームの時間を確実に削っていきます。
人に伝わっていない前提は、AI にも伝わりません。そして AI の推測が外れていても、コードは動き、見た目もそれらしく仕上がります。だからこそ、AI 生成コードの品質は個人の注意力ではなく、仕組みで守る必要があります。
この記事でいう「本番品質」
本記事では、次の 4 つを満たす状態を「本番品質」と呼びます。
- 仕様どおりに動き、推測で埋めた箇所がない
- 既存の設計・コンポーネント・規約に沿っている
- 壊れたときに、人より先に機械が気づける
- 半年後に別のメンバーが読んで直せる
これを守る仕組みを、① AI に渡すコンテキスト、② 機械的なガードレール、③ テスト、④ CI と人間のレビューの順に見ていきます。ツールの例には Claude Code を使いますが、考え方は Cursor や GitHub Copilot でも同じです。長い設定ファイルは折りたたんでいるので、必要なところだけ開いてください。
AI 生成コードで起きがちな品質問題
AI 生成コードの問題は「動かない」ことではなく、「動くけれど、そのプロジェクトのやり方に沿っていない」ことです。しかも、それが人の書くコードより高い頻度で起きます。
CodeRabbit が 2025 年 12 月に公開したレポートでは、OSS の PR 470 件(AI との共作 320 件、人のみ 150 件)を分析しています。AI 共作の PR は 1 件あたりの指摘が 10.83 件で、人のみの PR(6.45 件)の約 1.7 倍でした。差が最も大きかったのは可読性で 3 倍以上、エラーハンドリングの不足も約 2 倍です。
レポートは原因の一つとして、AI がリポジトリ固有の命名や設計の慣習に従いきれず、一般的な書き方に寄っていく点を挙げています。AI と共作したかを PR 上の痕跡から推定しているなど、ベンダー自身の調査としての限界はありますが、現場の感覚とはよく一致します。
Google の DORA 2025 レポートも同じ方向を示しています。回答者の 90% が仕事で AI を使う一方、AI の導入はデリバリーの安定性と負の関係にありました。自動テストや速いフィードバックループといった「制御の仕組み」がないまま変更量だけが増えると、不安定になるという説明です。
フロントエンドで現れる典型パターン
フロントエンドでは、次のような形で現れることが多いです。右の列は、このあと紹介する仕組みとの対応です。lint で止められるのは書き方のパターンだけなので、一部しか止められないものは「一部」としています。
| 症状 | 具体例 | 主に止める仕組み |
|---|---|---|
| 仕様の空白を既定値で埋める | 最大文字数やエラー文言、表示のタイミングを独自に決める | ① コンテキスト、③ テスト |
| 既存資産を使わず新しく作る | 既存の Button やデータ取得の処理と同じものを新規作成する | ① コンテキスト、② lint(一部)、④ レビュー |
| 型の逃げ道を使う |
any(as any を含む)、非 null アサーション、@ts-ignore
|
② 型チェック・lint |
| 型アサーションで辻褄を合わせる | API の戻り値を as User で決め打ちする |
④ レビュー |
| React / Next.js の作法から外れる | effect 内での同期的な setState、"use client" の付けすぎ |
② lint(一部)、④ レビュー |
| アクセシビリティが抜ける |
div に onClick、label のない入力欄 |
② lint、③ テスト |
| 読みにくいコードになる | 巨大なコンポーネント、書式や命名の不統一 | ② フォーマッタ・lint、④ レビュー |
| 依存パッケージを安易に増やす | 数行で書ける処理のためにライブラリを追加する | ④ 依存レビュー |
| テストを「通す」ために変える | 期待値の書き換え、skip / fixme の追加 |
② lint(skip / fixme)、④ レビュー |
4 つの層の全体像
| 層 | 効くタイミング | 役割 | 代表的な手段 |
|---|---|---|---|
| ① コンテキスト | 生成する前 | 推測の余地を減らす | AGENTS.md、仕様テンプレート |
| ② 機械的なガードレール | 生成直後〜コミット前 | 規約違反をその場で弾く | TypeScript、ESLint、Prettier、フック |
| ③ テスト | 生成直後〜CI | 振る舞いと見た目を固定する | Vitest、Storybook、Playwright |
| ④ CI と人間のレビュー | PR 作成後 | 最終判断と責任を持つ | 必須チェック、CODEOWNERS、レビュー観点 |
上の層ほど安く速く、AI 自身が直せます。下の層ほど人の時間を使います。そのため、問題はできるだけ上の層で止めるのが基本方針です。
仕組み① AI に渡すコンテキストを整える
1 つ目の層の目的は、AI が推測で埋める範囲を減らすことです。ただし、指示ファイルはあくまで「お願い」で、強制力はありません。この前提で、何をどこに書くかを決めます。
AGENTS.md を共通の指示ファイルにする
チーム内で使う AI ツールがバラバラなら、指示ファイルは AGENTS.md に寄せるのがおすすめです。AGENTS.md は Codex や Cursor など多くのエージェントが読む共通フォーマットで、現在は Linux Foundation 傘下の Agentic AI Foundation が管理しています。
Claude Code も v2.1.277(2026 年 9 月)から、CLAUDE.md がない場合に AGENTS.md を直接読むようになりました(公式ドキュメント)。CLAUDE.md と併用する場合は、CLAUDE.md の先頭に @AGENTS.md と書いてインポートし、その下に Claude 固有の指示だけを足します。
Next.js プロジェクトなら、たとえば次のような内容です。
# AGENTS.md
## コマンド
- 開発サーバー: `pnpm dev`
- 作業の完了前に必ず実行: `pnpm lint && pnpm format:check && pnpm typecheck && pnpm test`
## 構成
- ルーティング: `src/app/`(App Router)
- 機能単位のコード: `src/features/<feature>/`。feature 同士で直接 import しない
- 共通 UI: `src/components/ui/`。新しい部品を作る前に、ここにあるか確認する
## 実装ルール
- Server Component を基本とし、`"use client"` は状態やイベントが必要な末端だけに付ける
- API 呼び出しは `src/lib/api/` のクライアント経由で行う
- フォームの検証ルールは各 feature の `schema.ts`(zod)に集約する
## テスト
- 既存テストの期待値を変える場合は、理由を PR の説明に書く
- `skip` / `fixme` / `todo` を追加しない。必要な場合は理由を書いて PR で相談する
- スナップショットを更新しない(`playwright test -u` を実行しない)
## 仕様が書かれていないとき
- 最大文字数、上限を超えた入力を止めるか、必須かどうか、ボタンの活性条件、エラー文言と表示タイミングが仕様にない場合は、推測で実装しない
- その箇所に `TODO(spec):` コメントを残し、確認したい点を PR の説明に列挙する
最後のセクションが、冒頭で触れた「推測で埋める」問題への直接の対策です。AI に「推測しない」ことを明示し、空白を見える形で残させます。「テスト」のルールを支える仕掛けは仕組み②・③で、TODO(spec) の消し忘れを防ぐ仕掛けは仕組み④で紹介します。
書き方のコツ
Claude Code の公式ドキュメントには、指示ファイルの書き方の指針がまとまっています。他のツールにもそのまま当てはまる要点は次の 3 つです。
-
検証できる粒度で書く。 「変更をテストする」ではなく、「コミット前に
npm testを実行する」のように書きます。 -
短く保つ。 1 ファイル 200 行以下が目安で、長いほど守られにくくなります。特定のディレクトリだけのルールは、
.claude/rules/にpaths付きで分けます。 - 同じ指摘が 2 回出たら追記する。 AI が同じ間違いを繰り返したときや、レビューで「知っておくべきだった」ことが指摘されたときが追記のタイミングです。
そして最も大事なのは、同じドキュメントにある「CLAUDE.md は強制される設定ではなく、コンテキストとして扱われる」という点です。機械的に判定できるルールは、指示ファイルに書くより lint やフックで強制したほうが確実です。これが次の層の話です。
仕様の空白は、AI に渡す前に埋める
AGENTS.md に書けるのはプロジェクト共通のルールだけです。タスクごとの仕様の空白は、チケットの段階で埋める必要があります。AI に実装を任せる前に、少なくとも次の観点が仕様に書かれているかを確認します。
- 入力の制約(最大文字数、文字種、必須か任意か、上限を超えた入力を止めるか)
- 状態ごとの表示(初期表示、読み込み中、0 件、エラー、権限なし)
- 操作の条件(ボタンが押せる条件、二重送信の扱い)
- 文言とタイミング(エラーメッセージ、表示するタイミング、確認ダイアログ)
- 暗黙の前提(「既存の会員登録画面と同じ挙動」「デザインシステムの標準どおり」など)
特に最後の暗黙の前提は、チケットの冒頭に 1 行で書いておきます。人に伝わる形で書かれていれば、AI にも伝わります。
仕組み② 機械的なガードレールで規約違反をその場で弾く
指示ファイルのルールは破られることがあります。破られて困るルールは、型チェック・lint・フックで機械的に弾きます。AI にとって lint のエラーメッセージは、最も誤解の余地がない指示でもあります。
TypeScript を一段厳しくする
strict に加えて noUncheckedIndexedAccess を有効にします。配列やオブジェクトの添字アクセスが T | undefined になり、AI が省きがちな null チェックの漏れを型エラーとして検出できます。tsconfig.json の抜粋です。
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}
ESLint で「プロジェクトのやり方」をコードにする
Next.js 16 からは next lint が廃止され、ESLint CLI と Flat Config を直接使う形になりました(公式ドキュメント)。eslint-config-next をベースに、AI 対策のルールを足します。設定の全体は長いので折りたたみ、主なルールを表にまとめます(ESLint 9.39 と eslint-config-next 16.4 で動作を確認しました)。
| 目的 | 主なルール |
|---|---|
| 型の逃げ道をふさぐ |
no-explicit-any(as any を含む)、no-non-null-assertion、ban-ts-comment
|
| lint を黙らせない | eslint-comments の require-description、no-unlimited-disable
|
| API 呼び出しの入り口を 1 つにする |
src/lib/api の外で、axios の import と fetch / window.fetch / globalThis.fetch を禁止 |
| UI 部品の入り口を 1 つにする |
src/components/ui の外で、@radix-ui/* の import を禁止 |
| 依存の向きを固定する |
import/no-restricted-paths(共通層から features、feature 同士) |
| キーボードで操作できない要素を防ぐ |
jsx-a11y/click-events-have-key-events、jsx-a11y/no-static-element-interactions
|
| 読みやすさの最低ライン |
naming-convention、complexity、max-lines-per-function
|
| テストを黙ってスキップさせない |
no-restricted-syntax(skip / skipIf / fixme / todo) |
eslint.config.mjs の全体
import fs from 'node:fs'
import { defineConfig, globalIgnores } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import nextTs from 'eslint-config-next/typescript'
import prettier from 'eslint-config-prettier/flat'
import eslintComments from '@eslint-community/eslint-plugin-eslint-comments/configs'
// src/features 直下のディレクトリを feature の一覧とする(まだなければ空)
const featuresDir = new URL('./src/features', import.meta.url)
const features = fs.existsSync(featuresDir)
? fs
.readdirSync(featuresDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
: []
const apiMessage = 'API 呼び出しは src/lib/api のクライアントを使ってください。'
const skipMessage = 'skip / fixme などを追加する場合は、理由を書いて PR で相談してください。'
export default defineConfig([
...nextVitals,
...nextTs,
prettier,
eslintComments.recommended,
{
files: ['**/*.{ts,tsx}'],
rules: {
// 型の逃げ道をふさぐ(as any も no-explicit-any で検出される)
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/ban-ts-comment': [
'error',
{ 'ts-expect-error': 'allow-with-description' },
],
// lint を無効化するときは理由を必須にする
'@eslint-community/eslint-comments/require-description': 'error',
// API 呼び出しと UI 部品の入り口を 1 つにする
'no-restricted-imports': [
'error',
{
paths: [{ name: 'axios', message: apiMessage }],
patterns: [
{
group: ['@radix-ui/*'],
message: 'UI 部品は src/components/ui から import してください。',
},
],
},
],
'no-restricted-globals': ['error', { name: 'fetch', message: apiMessage }],
'no-restricted-properties': [
'error',
{ object: 'window', property: 'fetch', message: apiMessage },
{ object: 'globalThis', property: 'fetch', message: apiMessage },
],
// 依存の向きを固定する
'import/no-restricted-paths': [
'error',
{
zones: [
// 共通層(components / lib)から features を参照しない
{ target: ['./src/components', './src/lib'], from: './src/features' },
// feature 同士で直接 import しない
...features.map((name) => ({
target: `./src/features/${name}`,
from: './src/features',
except: [`./${name}`],
})),
],
},
],
// クリックできる div など、キーボードで操作できない要素を検出する
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'error',
// 読みやすさの最低ライン
'@typescript-eslint/naming-convention': [
'error',
{ selector: 'typeLike', format: ['PascalCase'] },
],
complexity: ['warn', 15],
'max-lines-per-function': [
'warn',
{ max: 100, skipBlankLines: true, skipComments: true },
],
},
},
{
// ラッパー自身はライブラリや fetch を直接使ってよい
files: ['src/components/ui/**', 'src/lib/api/**'],
rules: {
'no-restricted-imports': 'off',
'no-restricted-globals': 'off',
'no-restricted-properties': 'off',
},
},
{
// テストを黙ってスキップさせない(仕組み③)
files: ['**/*.{test,spec}.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"MemberExpression[object.name=/^(test|it|describe|suite)$/][property.name=/^(skip|skipIf|fixme|todo)$/]",
message: skipMessage,
},
{
selector:
"MemberExpression[object.property.name='describe'][property.name=/^(skip|fixme)$/]",
message: skipMessage,
},
],
},
},
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
])
ルールの message には、「何が駄目か」だけでなく「代わりに何を使うか」まで書きます。AI はこのメッセージを読んで修正するからです(例:「API 呼び出しは src/lib/api のクライアントを使ってください。」)。
AGENTS.md に書いたルールのうち、「API はクライアント経由で呼ぶ」「feature 同士で直接 import しない」は、この設定で lint に移しました。feature 同士の import 禁止は、src/features 直下のディレクトリから自動で作っています。ここで初めて、お願いだったルールが強制に変わります。
一方で、lint で止められるのは典型的な書き方までです。as による型アサーションは正当な用途も多いため禁止しておらず、既存と同じ部品を一から作り直すことも lint では検出できません。こうした部分は、仕組み④のレビュー観点に残します。
このほか、eslint-config-next に既定で含まれる react-hooks/set-state-in-effect は、effect の中で同期的に setState する実装を検出します。AI が書きがちな「props から state を作るだけの useEffect」の一部は、ここで止まります。また、AI に作り直しを頼むと古い実装が使われないまま残ることがあるため、Knip を CI で回して未使用のファイル・export・依存パッケージを検出します。
読みやすさの最低ラインを機械で守る
冒頭の基準 4「半年後に別のメンバーが読んで直せる」に対応する部分です。可読性は、CodeRabbit のレポートで最も差が大きかった項目でもあります。書式は Prettier に任せ、後述のフックで AI がファイルを編集するたびに整形し、CI でも prettier --check で確認します。型名の形式は naming-convention で PascalCase に固定します。
巨大な関数やコンポーネントには、complexity と max-lines-per-function を warning で設定しています。ただし warning は CI を止めず、後述のフックでも AI には返りません。エディタや pnpm lint で人が気づくための目印です。AI に必ず直させたい場合は error にするか、フックの ESLint に --max-warnings=0 を付けます。その場合、編集したファイルに既存の warning があると、AI がそれも直しにいく点に注意してください。
名前が意味として適切か、責務の分け方が妥当かは機械では判断できません。そこは仕組み④のレビュー観点に残します。
エージェントのフックで、書いた直後に検査する
lint を CI だけで回すと、AI が間違えてから気づくまでに時間がかかります。Claude Code の hooks を使うと、ファイルを編集した直後に検査を走らせ、結果を AI に返して直させられます。hooks は LLM の判断に依存せず、決まったタイミングで必ず実行されます(公式ガイド)。
設定するのは次の 2 つです。
-
編集のたび(
PostToolUse): 編集されたファイルを Prettier で整形してからeslint --fixをかけ、自動修正できないエラーが残れば終了コード 2 で AI に返す -
応答の終わり(
Stop): プロジェクト全体の型チェックを 1 回だけ走らせ、エラーがあれば作業を続けさせる
.claude/settings.json は次のとおりです。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-file.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/typecheck.sh" }
]
}
]
}
}
フックのスクリプト(lint-file.sh / typecheck.sh)
編集のたびに実行する整形と lint のスクリプトです。
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
# TypeScript 以外は対象外
[[ "$FILE_PATH" =~ \.(ts|tsx)$ ]] || exit 0
# 書式を整える
pnpm exec prettier --write "$FILE_PATH" > /dev/null
# 自動修正できるものは直し、残ったエラーだけを Claude に返す
if ! OUTPUT=$(pnpm exec eslint --fix "$FILE_PATH" 2>&1); then
echo "$OUTPUT" >&2
exit 2
fi
応答の終わりに実行する型チェックのスクリプトです。
#!/bin/bash
INPUT=$(cat)
# すでに 1 度止めた後の Stop は通す(無限ループ防止)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! OUTPUT=$(pnpm typecheck 2>&1); then
echo "型エラーが残っています。修正してから作業を終えてください。" >&2
echo "$OUTPUT" | head -n 30 >&2
exit 2
fi
型チェックを編集のたびに走らせると遅いので、Stop に寄せています。また、Claude は Bash 経由でもファイルを変更できるため、Edit|Write のフックだけではすべての変更を拾えません。AI を使わない変更も通る経路として、pre-commit(lefthook や husky + lint-staged)と CI にも同じチェックを置きます。
仕組み③ テストで振る舞いと見た目を固定する
テストは、AI が守るべき「正解」をコードとして固定するものです。ただし、AI が実装とテストを同じ推測から書くと、テストはその推測を追認するだけになります。そこで、何を確かめるかは仕様から人が決め、テストを書く作業を AI に任せます。
テスト観点は仕様から先に決める
仕組み①のチェックリストで埋めた仕様は、そのままテスト観点になります。たとえば、ニックネーム欄の仕様を次のように確定させたとします。
- 必須、最大 20 文字
- 20 文字を超えても入力は止めず、入力中に「20 文字以内で入力してください」と表示する
- エラーがある間は「保存」ボタンを押せない
この仕様なら、次のテストを実装より先に用意できます。実行に必要な Vitest の設定は、末尾の「サンプルを動かすための準備」にまとめています。
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { ProfileForm } from './ProfileForm'
// 仕様: 最大 20 文字。超過しても入力は止めず、入力中にエラーを表示して保存不可にする
test('21 文字入力するとエラーを表示し、保存できない', async () => {
const user = userEvent.setup()
render(<ProfileForm />)
await user.type(screen.getByLabelText('ニックネーム'), 'あ'.repeat(21))
expect(screen.getByText('20 文字以内で入力してください')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
})
仕様の数値・文言・タイミングがテストに書かれているので、AI が独自の既定値で実装すると失敗します。また getByLabelText や getByRole で要素を探すため、label のない入力欄や、ボタンではない「押せる div」も同時に検出できます。
注意したいのは、仕様が「最大 20 文字」だけの段階では、このテストを書けないことです。その段階では、maxLength で入力を止める実装も、送信時にまとめて検証する実装も仕様を満たします。テストがどちらかを決め打ちすると、テスト自体が「推測で埋める」問題を抱えてしまいます。テストを書く前に、まず仕様の空白を埋めます。
もう一つ注意したいのは、テストが失敗したときに AI が期待値のほうを書き換えるケースです。AGENTS.md のサンプルに入れた「既存テストの期待値を変える場合は、理由を PR の説明に書く」ルールに加えて、レビューでもテストの差分を重点的に見ます。
Storybook で状態のカタログを作る
AI が作るコンポーネントは、正常系の見た目だけが整っていることがよくあります。先ほどの CodeRabbit のレポートでも、エラーハンドリングの不足は約 2 倍でした。そこで、仕様のチェックリストにある状態(初期表示、読み込み中、0 件、エラー、長い文字列など)を、Storybook の story として 1 つずつ用意します。
Storybook の Vitest アドオンを使うと、story をそのままテストとして CI で実行できます。play 関数で操作を検証し、アクセシビリティアドオンの parameters.a11y.test を 'error' にすれば、違反があるとテストが失敗します。なお、Next.js で Vitest アドオンを使う場合は @storybook/nextjs-vite フレームワークが必要です。
VRT で「ついでの変更」を検出する
AI は頼んだ箇所以外も「ついでに」直すことがあります。共通コンポーネントの余白やスタイルが変わっても、ユニットテストは通ってしまいます。これを拾うのが VRT(ビジュアルリグレッションテスト)で、Playwright の toHaveScreenshot、Storybook と組み合わせる reg-suit や Chromatic などが選択肢になります。Playwright の場合、ベースラインは OS ごとに別のファイルになるため、CI と同じ Linux 環境で作成しておきます。
運用で大事なのは、ベースライン画像の更新を人の判断に限定することです。AI に「テストが落ちたので画像を更新しました」とされると、VRT は意味を失います。
ただし、ベースラインは npx playwright test -u のように Bash 経由で更新できるため、Edit|Write を対象にしたフックでは防げません。AGENTS.md で「スナップショットを更新しない」と指示したうえで、最後の砦は PR の差分に置きます。どの経路で更新されても、ベースラインの変更は必ず PR の差分に現れるからです。CODEOWNERS でベースラインの承認者を指定し、ブランチ保護で Code Owners のレビューを必須にします。
# Playwright のベースライン画像(既定では *-snapshots/ に保存される)
**/*-snapshots/ @your-org/design-reviewers
E2E は重要な導線に絞る
E2E は、ログインや申し込みなど、壊れると業務が止まる導線に絞って Playwright で書きます。Playwright 1.56 からは、テストの計画・生成・修復を担う Test Agents(planner / generator / healer)も使えます。
ただし healer は、機能そのものが壊れていると判断した場合、テストを test.fixme() などでスキップ扱いにして終えることがあります。修復の結果がスキップされたテストになりうることは、公式ドキュメントにも明記されています。CI が緑でも、確認している内容が減っていては意味がありません。
そこで、仕組み②の eslint.config.mjs の最後のブロックで、テストファイルでの skip / skipIf / fixme / todo を lint エラーにしています。どうしても必要な場合は eslint-disable で無効化しますが、require-description によって理由の記述が必須になるため、PR の差分に必ず理由が残ります。
仕組み④ CI と人間のレビューで最終判断する
最後の層は、すべての変更が必ず通る関門です。ここでは、機械に任せる確認と、人が判断することをはっきり分けます。
ローカルと同じチェックを CI の必須条件にする
AGENTS.md の「完了前に必ず実行」と同じコマンドに、Knip、TODO(spec) の確認、Playwright の E2E・VRT を加えて CI で実行し、ブランチ保護の必須チェックにします。
name: quality
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
- run: pnpm typecheck
- run: pnpm test
- run: pnpm knip
- name: 未確認の仕様が残っていないか
run: |
if git grep -n "TODO(spec)" -- src; then
echo "TODO(spec) が残っています。仕様を確認してからマージしてください。"
exit 1
fi
# E2E と VRT(playwright.config.ts の webServer でアプリを起動する前提)
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec playwright test
なお、TODO(spec) のチェックはコメントを消せば通ります。仕様確認の漏れを防ぐリマインダーと位置づけ、確認した内容は後述の PR テンプレートの「仕様の確認事項」に残します。
依存パッケージの追加は人が確認する
AI は数行で書ける処理にもライブラリを追加しがちです。さらに、実在しないパッケージ名を提案することもあります。USENIX Security 2025 で発表された研究では、16 のモデルが生成したコードで推奨されたパッケージの 19.7% が実在しないものでした(CSA の解説)。その名前を攻撃者が先に登録しておく「slopsquatting」という攻撃も知られています。数値は 2025 年時点のモデルによるものなので、最新のモデルでは差がある点には注意してください。
そのため、依存の追加を含む PR では「本当に必要か」「意図した公式のパッケージか」を人が確認します。名前の綴り、公開元、利用実績を見るこの確認が、slopsquatting への主な対策です。
あわせて、pnpm の minimumReleaseAge で公開直後のバージョンをインストールしないようにします。こちらは、乗っ取られたパッケージから悪意のあるバージョンが公開され、短期間で発覚するような攻撃への備えです。経過時間で判断する設定なので、slopsquatting の直接の対策にはなりません。
pnpm 11 以降は既定で 1440 分(1 日)待ちますが、この既定値は非 strict です。条件を満たすバージョンがなければ、待ち時間を満たさない新しいバージョンが入ります。minimumReleaseAge を明示的に設定すると strict が既定で有効になり、条件を満たせない場合はインストールが失敗します(pnpm のドキュメント)。
# 明示的に設定すると、minimumReleaseAgeStrict も既定で true になる
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
人は「機械では判断できないこと」に集中する
AI レビューツールは一次チェックとして役に立ちますが、マージの責任を持つのは人です。人の時間は、機械では判断できない観点に使います。
| 観点 | 主な担当 | 手段 |
|---|---|---|
| 書式・命名の形式・import のルール | 機械 | Prettier、ESLint、フック |
| 型安全性・null チェック | 機械 | TypeScript、ESLint |
| 仕様どおりの振る舞い | 機械 + 人 | テスト、仕様書との突き合わせ |
| 見た目の意図しない変化 | 機械 + 人 | VRT(差分の承認は人) |
| 依存パッケージの追加 | 人 + 機械 | 名前と公開元の確認、公開待ち期間の設定 |
型アサーション(as)の妥当性 |
人 | コードレビュー |
| 設計判断・責務の分け方・命名の意味 | 人 | コードレビュー |
| 文言・UX の妥当性 | 人 | レビュー、デザイナーの確認 |
PR テンプレートにも、人が確認したことを書く欄を設けます。CodeRabbit のレポートも、AI 生成コードを前提にしたレビュー用のチェックリストを推奨しています。
## 仕様の根拠
<!-- チケットや仕様書の該当箇所 -->
## 仕様の確認事項
<!-- TODO(spec) として残した箇所と、確認した内容 -->
## AI に任せた範囲
<!-- 例: フォームの実装、テストコードの作成 -->
## 変更の理由(該当する場合)
<!-- 既存テストの期待値の変更、skip / fixme の追加、依存パッケージの追加をした理由 -->
## 人が確認したこと
- [ ] 仕様の数値・文言・タイミングがテストに反映されている
- [ ] TODO(spec) をすべて解消し、確認した内容を「仕様の確認事項」に書いた
- [ ] 期待値の変更・skip / fixme・依存の追加がある場合は、「変更の理由」に書いた
- [ ] 追加した依存パッケージの名前と公開元を確認した
- [ ] 画面の差分(スクリーンショット / VRT)を確認した
「AI に任せた範囲」は、誰かを責めるための欄ではありません。レビュアーがどこを重点的に読むべきかを判断するための欄です。
導入のステップと運用のコツ
導入の順番
すべてを一度に入れる必要はありません。効果が大きく、導入コストが小さい順に入れていきます。
-
完了の定義をコマンドにする。
lint・format:check・typecheck・testを package.json にそろえ、CI の必須チェックにします。 - AGENTS.md を短く書く。 コマンド、構成、テストのルール、そして「仕様がないときは推測しない」を書きます。
- フックを入れる。 編集後の整形・lint と、応答終了時の型チェックから始めます。
- 仕様チェックリストをチケットのテンプレートに入れる。 そのままテスト観点として使えます。
- 共通コンポーネントから Storybook と VRT を整える。 影響範囲が広い部品ほど、「ついでの変更」の被害が大きいからです。
サンプルを動かすための準備
準備の手順(scripts・Vitest の設定・インストール)
本文のサンプルは、Next.js のプロジェクトに ESLint と eslint-config-next が入っている前提です。Vitest の設定は Next.js の Vitest ガイドをもとに、jest-dom の読み込み、テストごとの cleanup、対象パスの指定を足しています(Vitest 5.1、TypeScript 6.0 で動作を確認しました)。Playwright と Storybook の導入は、それぞれの公式ドキュメントに従ってください。
{
"scripts": {
"lint": "eslint .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"knip": "knip"
}
}
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
// Playwright の E2E(e2e/**/*.spec.ts)は対象外にする
include: ['src/**/*.test.{ts,tsx}'],
},
})
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
# lint・書式・未使用コードの検出
pnpm add -D prettier eslint-config-prettier @eslint-community/eslint-plugin-eslint-comments knip
# ユニットテスト
pnpm add -D vitest @vitejs/plugin-react jsdom vite-tsconfig-paths @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
# フックのスクリプトに実行権限を付ける(スクリプト内で jq を使います)
chmod +x .claude/hooks/*.sh
運用のコツ
運用では、次の 4 点が大切です。
- 同じ指摘が 2 回出たら、仕組みに落とす。 レビューで同じ指摘をしたら、まず lint ルールにできないかを考え、難しければ AGENTS.md に書きます。指摘をコメントで終わらせないことが、再発防止の近道です。
-
ルールは warning から始める。 既存コードに違反が多いルールをいきなり error にすると、人も AI も
eslint-disableで逃げがちです。違反を減らしてから error に上げます。 -
指示ファイルも定期的に見直す。 古いルールや矛盾したルールがあると、AI はどちらかを任意に選んでしまいます。Claude Code なら
/doctor prompt-auditで、古い記述や矛盾を点検できます。 - 効果を数字で見る。 仕様とのズレを直す修正 PR の件数、レビュー指摘の内訳、QA で見つかった仕様起因の不具合数などを追うと、どの層が効いていて、どこが穴なのかが見えます。
まとめ
AI 生成コードの品質は、AI の賢さよりも、AI の周りに置いた仕組みで決まります。
- ① コンテキスト: AGENTS.md と仕様チェックリストで、推測の余地を減らす
- ② ガードレール: 型・lint・フォーマッタ・フックで、規約違反をその場で弾く
- ③ テスト: 仕様から決めた観点で、振る舞いと見た目を固定する
- ④ CI とレビュー: 機械に任せる確認と、人が責任を持つ判断を分ける
AI が推測で埋めてしまう空白の多くは、実は人にも伝わっていない前提です。仕組みを整えることは、AI のためだけでなく、チームの認識をそろえることにもつながります。まずは「完了の定義をコマンドにする」ところから始めてみてください。
参考
- Our new report: AI code creates 1.7x more problems(CodeRabbit、2025 年 12 月)
- Announcing the 2025 DORA Report(Google Cloud、2025 年 9 月)
- Claude があなたのプロジェクトを記憶する方法(Claude Code ドキュメント)
- hooks でアクションを自動化する(Claude Code ドキュメント)
- Claude Code now also accepts instructions in OpenAI’s Agents.md format(InfoWorld、2026 年 9 月)
- ESLint Plugin(Next.js ドキュメント)
- How to set up Vitest with Next.js(Next.js ドキュメント)
- Vitest addon(Storybook ドキュメント)
- Playwright Test Agents(Playwright ドキュメント)
- pnpm の設定:依存関係の解決(pnpm ドキュメント)
- Mitigating supply chain attacks(pnpm ドキュメント)
- Slopsquatting: AI Code Hallucinations Fuel Supply Chain Attacks(Cloud Security Alliance、2026 年 4 月)
Discussion
