はじめに
AIにコードを書かせるようになって、人間の作業は「コードを書く」から「コードが規約を守る仕組みを作る」へ移りつつあります。
この記事は、その仕組みづくりを実際に手を動かして体験するための学習ガイドです。題材として、Hono + TypeScript でオニオンアーキテクチャのタスク管理APIを作ります。ただし、ソースコードを書くのはあなたではありません。
- 作業者は人間(あなた)とClaude Codeの2者
- あなたは
src/とtests/を最後まで一度も編集しない - あなたが作るのは、Claudeが書いたコードを規約どおりに保つ仕組み
この仕組みを本記事ではハーネスと呼びます。
前提
- Node.js 22以上(dependency-cruiser が要求します)
- Claude Code が使えること
- Git
- オニオンアーキテクチャの用語(domain / application / infrastructure / presentation の4層)を聞いたことがある程度
この記事の進み方
手順1〜5でハーネスを組み立て、手順6〜11でClaudeに実装させながらハーネスを検証・強化します。手を動かすと3〜4時間程度です。
問題
「オニオンアーキテクチャで書いて」とClaudeに頼むと、最初はきれいに書いてくれます。ところが機能を足していくうちに、domain 層に new Date() が現れ、application 層のインターフェースが findByIdWithJoin(sql: string) になり、内側の層が npm パッケージを import し始めます。
CLAUDE.md に規約を書いても同じです。ここには3つの別々の問題があります。
1. 文書は守られる保証がない
CLAUDE.md は読まれますが、従われるとは限りません。長いセッションの後半ほど守られなくなります。これは指示の書き方の問題ではなく、「守ってほしい」と書くことと「守られている」ことは別物という構造の問題です。
2. 破ったことに人間が気づけない
生成される量が多いので、全部を読んでレビューすることは現実的ではありません。動いてテストが通っていれば、層の境界が壊れていても気づきません。気づくのは、変更が高くつくようになってからです。
3. 気づいても、直し方が蓄積しない
チャットで「そこは Clock インターフェースを切って」と伝えれば直ります。しかし次のセッションでは同じ違反が起きます。あなたの指摘はリポジトリに残っていないからです。口頭で直すたびに、あなたが規約の一部を肩代わりしていることになります。
つまり、人間がレビュー役に立ち続ける限り、人間がボトルネックになります。必要なのはレビューを速くすることではなく、違反したコードがそもそも成立しない状態を作ることです。
解決方法
ハーネスという考え方
ハーネスを次の2つに分けます。
- ガイド(フィードフォワード) — Claudeが書く前に、書ける範囲そのものを狭める仕組み
- センサー(フィードバック) — Claudeが書いたあとにコードを検査し、違反を自己修正させる仕組み
センサーはさらに2種類に分かれます。本記事ではこの2語を使い分けます。
| 用語 | 実行するもの | 性質 | 実体 |
|---|---|---|---|
| 計算的センサー | CPU上のプログラム | 速い。同じ入力なら必ず同じ結果 |
npm run harness:fast と npm run harness:slow
|
| 推論的センサー | LLM(Claude) | 遅い。結果がぶれる。意味の判断ができる | /architecture-review |
「センサー」とだけ書いた場合は両方を指します。片方だけを指すときは必ず「計算的」「推論的」を付けます。
先に作るのはガイドと計算的センサーです。推論的センサーは、計算的センサーの限界を自分の目で確かめてから作るほうが必要性が分かるため、手順10まで置きます。
作るファイル
| 分類 | ファイル | 役割 |
|---|---|---|
| ガイド | tsconfig.json |
型の厳しさで書ける範囲を狭める |
| ガイド | .claude/settings.json |
触れるファイルと実行できるコマンドを限定する |
| ガイド | CLAUDE.md |
規約と必須手順をClaudeに伝える |
| ガイド | docs/spec.md |
何が正しいかを定める |
| ガイド | docs/features.md |
どこまで作るかを定める |
| ガイド | docs/how-to-test.md |
テストの期待値をどこから導くかを定める |
| 計算的センサー | vitest.config.ts |
テストの実行範囲を決める |
| 計算的センサー | eslint.config.js |
現在時刻・乱数・環境変数への直接アクセスを検出する |
| 計算的センサー | .dependency-cruiser.cjs |
層をまたぐimportと、内側2層への外部モジュールのimportを検出する |
| 計算的センサー | scripts/check-ports.mjs |
src/application に現れる実装技術の語を検出する |
| 計算的センサー | stryker.config.json |
テストが実装の誤りを検出できるかを測る |
| 計算的センサー | knip.json |
どこからも使われていないコードを検出する |
| 計算的センサー | package.json |
上記を2つのコマンドに束ねる |
| 推論的センサー | .claude/commands/architecture-review.md |
意味の判断が必要な問題を検出する |
前提: 作るアプリケーションと層構成
作るものはHono製のタスク管理API。層構成は次のとおりです。
src/domain/ エンティティ、値オブジェクト、ドメイン例外
src/application/ ユースケース、ドメインサービスのインターフェース
src/infrastructure/ ドメインサービスのインターフェースの実装
src/presentation/ Honoのルーター、依存の組み立て
依存は外側から内側への一方向に限ります。domain と application は、データベース・現在時刻・乱数・環境変数・npmパッケージ・Node.js標準モジュールのいずれにも直接触れません。
ドメインサービスのインターフェース
内側の層が外部リソースを使うために、src/application に置くインターフェースのことです。実装は src/infrastructure に書き、src/presentation で注入します。
これはこのタスク管理APIのソースコードとして、これから作るものです。Claude Codeの機能でもNode.jsの標準ライブラリでもありません。
// src/application/task-repository.ts
// 保存手段を知らないまま「保存できる何か」を要求する
export interface TaskRepository {
findById(id: TaskId): Promise<Task | undefined>;
}
// src/application/clock.ts
// 現在時刻を得る手段を要求する。Node.js の Date を直接使わないための入れ物
export interface Clock {
now(): Date;
}
domain と application では new Date() を書かない、とあなたが決めます。決めただけでは守られないので、書かれていたら検査で止まるようにします。そうすると、現在時刻を得るには上の Clock のようなインターフェースを定義して外から渡してもらう以外に道がなくなります。その検査を担うのが手順3で作る計算的センサーです。
これらのインターフェースはClaudeが書きます。 あなたは書きません。
手順1: 開発環境を用意する
Claudeが作業を始める前に、パッケージ・ディレクトリ・Gitを揃えます。
1-1. Node.js 22以上であることを確認する
dependency-cruiser が Node 22 以上を要求します。
node -v
→ v22 以上であること。満たしていなければアップグレードします。
1-2. プロジェクトを作成する
WindowsではGit BashまたはWSLで実行します。
mkdir hono-onion-harness && cd hono-onion-harness
npm init -y
npm install hono @hono/node-server
npm install -D typescript@5 @types/node tsx vitest @vitest/coverage-v8 \
eslint@9 @eslint/js@9 typescript-eslint \
dependency-cruiser jscpd knip \
@stryker-mutator/core @stryker-mutator/vitest-runner
git init
mkdir -p src/domain src/application src/infrastructure src/presentation \
tests docs scripts .claude/commands
typescript@5 と eslint@9 はバージョン固定が必要です。TypeScript 7系はtypescript-eslintのサポート範囲外で、ESLint実行時にクラッシュします。
.claude/commands は手順10で推論的センサーを置く場所です。ここで作っておきます。
1-3. .gitignore — 生成物をGitの対象から外す
node_modules/ 以外は、これから作る計算的センサーが実行時に生成する中間物です。.stryker-tmp/ はStrykerがテストを複製する場所、coverage/ と reports/ は測定結果の出力先です。
node_modules/
.stryker-tmp/
dist/
coverage/
reports/
*.log
手順2: ガイド(フィードフォワード)を作る
Claudeが動く前に、書ける範囲そのものを狭めます。手段は2つあります。設定で物理的に不可能にするもの(2-1、2-2)と、文書で規約として伝えるもの(2-4以降)です。前者だけでは意味の指定ができず、後者だけでは守られる保証がありません。両方を置きます。
2-1. tsconfig.json — 型を厳しくし、書ける範囲を狭める
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noEmit": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true
},
"include": ["src", "tests"]
}
moduleResolution: nodenext のため、相対importには .js 拡張子が必要になります。
2-2. .claude/settings.json — 触れるファイルとコマンドを限定する
{
"permissions": {
"defaultMode": "dontAsk",
"deny": [
"Edit(CLAUDE.md)",
"Edit(docs/**)",
"Edit(scripts/**)",
"Edit(tsconfig.json)",
"Edit(package.json)",
"Edit(eslint.config.js)",
"Edit(vitest.config.ts)",
"Edit(.dependency-cruiser.cjs)",
"Edit(stryker.config.json)",
"Edit(knip.json)",
"Edit(.claude/**)",
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(cp:*)",
"Bash(sed:*)",
"Bash(tee:*)",
"Bash(node -e:*)",
"Bash(node --eval:*)",
"Bash(sh -c:*)",
"Bash(bash -c:*)",
"Bash(python -c:*)",
"Bash(python3 -c:*)",
"Bash(npx:*)",
"Bash(git config:*)",
"Bash(git -c:*)",
"Bash(git checkout:*)",
"Bash(git reset:*)",
"Bash(git push:*)",
"Bash(git commit:*)"
],
"allow": [
"Read(**)",
"Edit(src/**)",
"Edit(tests/**)",
"Bash(npm run:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(wc:*)",
"Bash(mkdir:*)",
"Bash(git status)",
"Bash(git diff:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npm run harness:fast"
}
]
}
]
}
}
設定の要点です。
| 項目 | 内容 |
|---|---|
defaultMode: "dontAsk" |
allow に列挙したものだけ実行し、それ以外は確認せず拒否する。承認ダイアログが出ない |
Write(...) を書かない |
ファイル権限の照合に使われるのは Edit(path) と Read(path) のルールだけ。Write にパスを書いたルールは受理されるが参照されない。Edit ルールがファイルを編集するすべてのツールを対象にするため、これだけで足りる |
deny の対象 |
ハーネスを構成するファイルと、任意のコードを実行できるコマンド。node -e や sh -c は引数の文字列を実行するため、rm を禁止しても迂回経路になる |
Bash(npx:*) の禁止 |
npx vitest のような直接実行を塞ぎ、実行経路を npm run の1本に固定する |
Bash(git config:*) と Bash(git -c:*) の禁止 |
--global はリポジトリ外の設定を変更する。git -c キー=値 はそのコマンド1回だけ設定を渡す書き方で、設定ファイルは変更しないが同じ効果を持つ。片方だけ禁止しても意味がない |
Bash(git commit:*) の禁止 |
コミットの単位と時点は人間が決める |
PostToolUse フック |
ファイル編集のたびに harness:fast を自動実行する。コマンドの中身は手順3で定義する |
初回の調整: dontAsk では列挙外のツールが拒否されます。Claudeが「拒否された」と報告したら、そのツール名またはコマンドを allow に追加します。追加してよいか判断できないものは追加しません。
2-3. safe.directory を設定し、コンテナ内でgitを使えるようにする
Dockerでコンテナ内から作業する場合のみ実施します。 該当しなければ2-4へ進んでください。
コンテナ内では、リポジトリの所有者(ホストのユーザー)と実行ユーザー(コンテナ内のユーザー)が一致せず、Gitが操作を拒否します。2-2で Bash(git -c:*) を禁止したため、Claudeは自分でこれを回避できません。この設定を先に済ませておかないと、Claudeが git status すら実行できなくなります。
Claude Codeを起動したあと、あなた自身が次を実行します。! を付けるとあなたの操作として実行され、権限ルールの対象外になります。
! git config --global --add safe.directory /workspace
パスはコンテナ内のプロジェクトルートに読み替えます。確認します。
! git config --global --get-all safe.directory
→ 設定したパスが表示されること。
/home/node などのホームディレクトリが名前付きボリュームになっていれば、次回以降のコンテナにも残ります。イメージの再ビルドは不要です。
2-4. Claudeが読む4つの文書の役割
ここから4つのマークダウンを書きます。役割を分けておくと、あとで問題が起きたときに直す場所が1つに定まります。
| ファイル | 定めること | 直すきっかけ |
|---|---|---|
CLAUDE.md |
どう振る舞うか(規約・必須手順・禁止コマンド) | Claudeが手順を踏まなかったとき |
docs/spec.md |
何が正しいか(業務ルール・エンドポイント) | 仕様の抜けが見つかったとき |
docs/features.md |
どこまで作るか(実装の単位と順序) | 実装範囲を進めるとき |
docs/how-to-test.md |
期待値をどこから導くか | テストが実装の写しになっていたとき |
いずれも .claude/settings.json の deny で、Claudeからの編集を禁止してあります。これらを書き換えられるのはあなただけです。
2-5. CLAUDE.md — 規約と必須手順を伝える
# プロジェクト指示
Hono製のタスク管理API。厳密なオニオンアーキテクチャを採用する。
仕様は @docs/spec.md、実装する機能の範囲は @docs/features.md、
テストの書き方は @docs/how-to-test.md を読む。
## コマンド
| 目的 | コマンド |
|---|---|
| 変更後の必須検査 | `npm run harness:fast` |
| 開発サーバー起動 | `npm run dev` |
`npx` から始まるコマンドを直接実行しない。すべて `npm run` を使う。
任意のコードを実行するコマンド(`node -e`、`sh -c` など)は使わない。
ファイルの読み取りには `cat`、`ls`、`grep`、`find`、`head` を使う。
## 必須手順
ファイルを編集したら `npm run harness:fast` を実行する。
検査が失敗したら、出力されたメッセージの修正手順に従って修正し、再度実行する。
すべて成功するまで作業を終えない。
## 層構成
src/domain/ エンティティ、値オブジェクト、ドメイン例外
src/application/ ユースケース、ドメインサービスのインターフェース
src/infrastructure/ ドメインサービスのインターフェースの実装
src/presentation/ Honoのルーター、HTTPハンドラ、依存の組み立て
src/index.ts 起動エントリポイント
## 依存ルール
| 層 | importしてよい相手 |
|---|---|
| `domain` | 同じ `domain` 層内のファイルのみ |
| `application` | `domain` |
| `infrastructure` | `domain`、`application` |
| `presentation` | すべての層 |
## 外部リソースへの直接アクセスの禁止
`domain` 層と `application` 層は外部リソースに直接触れない。
以下は `src/application` にインターフェースを定義し、
`src/infrastructure` で実装し、`src/presentation` で注入する。
- データベース、ファイル
- システムクロック(`new Date()`、`Date.now()`)
- 乱数生成器(`Math.random()`)
- 環境変数(`process.env`)
- Node.js標準モジュール、npmパッケージ
インターフェース名と引数に、実装技術を示す語を含めない。
`sql`、`query`、`table`、`http`、`prisma` などが該当する。
## 規約
- テストは `tests/` 配下に置き、`*.test.ts` という名前にする。
- テストの期待値は @docs/spec.md の記述から導く。実装コードから写さない。
- 相対importに `.js` 拡張子を付ける。
2-6. docs/spec.md — 何が正しいかを定める
実装方法は書きません。「どう作るか」を書いた瞬間に、ハーネスが機能したかどうかを確かめられなくなります。 Claudeが設計を自力で導いたのか、あなたが教えたのかが区別できなくなるためです。
# タスク管理API 仕様
## 用語
- タスク: やるべきことを1件表したもの。ID、タイトル、完了状態を持つ。
- 完了: タスクが済んだ状態。一度完了したタスクは未完了に戻せない。
## データ構造
| 項目 | 型 | 説明 |
|---|---|---|
| id | 文字列 | タスクを一意に識別する値。作成時に自動で採番する |
| title | 文字列 | タスクの内容 |
| done | 真偽値 | 完了していれば true、していなければ false |
| dueDate | 文字列またはnull | 期限日。YYYY-MM-DD形式。指定しない場合は null |
| overdue | 真偽値 | 期限切れであれば true、そうでなければ false。応答にのみ含める |
## 業務ルール
1. タイトルは1文字以上100文字以下とする。
2. 空白文字だけのタイトルは受け付けない。
3. 新規作成したタスクの done は false とする。
4. 完了済みのタスク(done が true)を再度完了させることはできない。
5. 期限(dueDate)の指定は任意とする。指定する場合はYYYY-MM-DD形式の実在する日付でなければならない。
6. 日付の比較はすべて協定世界時(UTC)を基準とする。サーバーの地域設定に影響されてはならない。
7. 期限がUTCでの現在の日付より前であり、かつ done が false のタスクを期限切れ(overdue)とする。
8. 完了済みのタスク(done が true)は、期限を過ぎていても期限切れとしない。
## エンドポイント
### POST /tasks
リクエスト本文:
{ "title": "牛乳を買う", "dueDate": "2026-12-31" }
dueDate は省略できる。省略した場合、または null を指定した場合は期限なしのタスクとなる。
成功時: ステータスコード 201。本文は作成されたタスク。
{ "id": "...", "title": "牛乳を買う", "done": false, "dueDate": "2026-12-31", "overdue": false }
失敗時:
| 条件 | ステータスコード | 本文 |
|---|---|---|
| title が0文字、または101文字以上 | 400 | { "error": "invalid title" } |
| title が空白文字のみ | 400 | { "error": "invalid title" } |
| title の項目自体が存在しない | 400 | { "error": "invalid title" } |
| dueDate がYYYY-MM-DD形式でない、または実在しない日付 | 400 | { "error": "invalid dueDate" } |
### GET /tasks
クエリパラメータ:
| 名前 | 値 | 動作 |
|---|---|---|
| status | done | 完了済みのタスクだけを返す |
| status | todo | 未完了のタスクだけを返す |
| 指定なし | — | すべてのタスクを返す |
成功時: ステータスコード 200。本文はタスクの配列。
該当が0件の場合も 200 を返し、本文は空の配列とする。
配列内の各タスクには dueDate と overdue を含める。
失敗時:
| 条件 | ステータスコード | 本文 |
|---|---|---|
| status が done でも todo でもない | 400 | { "error": "invalid status" } |
### POST /tasks/:id/complete
リクエスト本文: なし。
成功時: ステータスコード 200。本文は完了後のタスク。
応答に含まれる overdue は、完了済みとなるため常に false となる。
失敗時:
| 条件 | ステータスコード | 本文 |
|---|---|---|
| 指定したIDのタスクが存在しない | 404 | { "error": "task not found" } |
| 指定したタスクの done が既に true | 409 | { "error": "already completed" } |
### DELETE /tasks/:id
成功時: ステータスコード 200。本文は削除したタスクのID。
{ "deleted": "..." }
失敗時:
| 条件 | ステータスコード | 本文 |
|---|---|---|
| 指定したIDのタスクが存在しない | 404 | { "error": "task not found" } |
## 仕様に含めないこと
次の項目は対象外とする。独自に追加してはならない。
- 認証、認可
- タスクの更新(タイトルの変更)
- ページング、並び替え
- データの永続化(サーバーを再起動すると全タスクが消えてよい)
2-7. docs/features.md — どこまで作るかを定める
docs/spec.md が「何が正しいか」を定めるのに対し、docs/features.md は「どこまで作るか」を定めます。仕様は全部書いておき、実装する範囲を機能単位で切り出します。
こうしておくと、Claudeへの指示が「機能1〜5を実装してください」で済みます。仕様書に無い機能を口頭で頼む必要がなくなり、実装されたものは必ず仕様書に対応する記述を持ちます。
# 実装する機能
仕様は docs/spec.md に定義済み。ここでは実装の単位と順序だけを定める。
指示された番号の機能だけを実装する。指示されていない番号には手を付けない。
| # | 機能 | 対応するエンドポイント | 対応する業務ルール |
|---|---|---|---|
| 1 | タスクを作成する | POST /tasks | 1, 2, 3 |
| 2 | タスクを一覧する | GET /tasks | — |
| 3 | 完了状態で一覧を絞り込む | GET /tasks?status= | — |
| 4 | タスクを完了する | POST /tasks/:id/complete | 4 |
| 5 | タスクを削除する | DELETE /tasks/:id | — |
| 6 | タスクに期限を設定し、期限切れを判別する | 上記すべての応答に dueDate と overdue を追加 | 5, 6, 7, 8 |
## 未実装の機能の扱い
機能6を実装するまで、応答に dueDate と overdue を含めない。
docs/spec.md に記述があっても、指示されていない機能は実装しない。
2-8. docs/how-to-test.md — 期待値をどこから導くかを定める
テストの期待値を実装コードから写されると、実装が仕様と食い違っていてもテストは通ってしまいます。それを防ぐために、期待値の出どころを docs/spec.md に固定します。
# テストの書き方
## 置き場所と名前
- テストは `tests/` 配下に置く。
- ファイル名は `*.test.ts` とする。
## 期待値の決め方
- 期待値は docs/spec.md の記述から導く。実装コードを読んで写してはならない。
- テスト名には、検査している仕様の記述をそのまま書く。
## 書く範囲
- docs/spec.md の業務ルールの各項目について、成功する場合と失敗する場合を1つずつ書く。
- 境界値を必ず含める。1文字以上100文字以下なら、0文字・1文字・100文字・101文字を検査する。
## 禁止事項
- domain 層と application 層のテストで、HTTPサーバーを起動しない。
- domain 層と application 層のテストで、実物の Clock と IdGenerator を使わない。
固定値を返す偽物を渡す。
## 書き方の例
import { describe, it, expect } from 'vitest';
import { Task } from '../src/domain/task.js';
describe('タイトルは1文字以上100文字以下とする', () => {
it('1文字のタイトルは受け付ける', () => {
expect(() => Task.create('a')).not.toThrow();
});
it('100文字のタイトルは受け付ける', () => {
expect(() => Task.create('a'.repeat(100))).not.toThrow();
});
it('0文字のタイトルは拒否する', () => {
expect(() => Task.create('')).toThrow();
});
it('101文字のタイトルは拒否する', () => {
expect(() => Task.create('a'.repeat(101))).toThrow();
});
});
手順3: 計算的センサー(フィードバック)を作る
Claudeが書いたあとに検査し、違反を自己修正させる仕組みです。実行時間で2つのコマンドに分けます。
| コマンド | 中身 | 実行する場面 |
|---|---|---|
harness:fast |
型検査・lint・依存関係・命名・テストの5つ | ファイル編集のたびに、2-2のフックが自動実行する |
harness:slow |
カバレッジ・ミューテーション・重複・未使用コードの4つ | 節目であなたが手動実行する(手順11) |
分ける理由は2つあります。harness:slow の4つは実行に数十秒から数分かかること。もう1つは、これらが書き終わるまで判定できないことです。インターフェースを定義した直後は実装がまだ無く、knip は必ず「未使用」と報告します。編集のたびに走らせれば誤検出になります。
3-1. vitest.config.ts — テストの実行範囲を決める
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
exclude: ['node_modules/**', '.stryker-tmp/**', 'dist/**'],
},
});
.stryker-tmp/** の除外は必須です。.gitignore はGitの対象を制御するだけで、Vitestの探索範囲には影響しません。除外しないとStrykerが残したテストの複製を実行してしまいます。
3-2. eslint.config.js — 現在時刻・乱数・環境変数への直接アクセスを検出する
importを伴わずに外部リソースへ触れる経路を塞ぎます。new Date() も Math.random() も process.env も、importなしで書けてしまうため、次の3-3では検出できません。
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
const noExternalResources = {
'no-restricted-syntax': ['error', {
selector: "NewExpression[callee.name='Date']",
message:
'システムクロックへの直接アクセスは禁止。' +
'修正手順: src/application に interface Clock { now(): Date } を定義し、' +
'src/infrastructure で実装し、src/presentation で注入して clock.now() を使う。',
}],
'no-restricted-properties': ['error',
{
object: 'Date',
property: 'now',
message: 'システムクロックへの直接アクセスは禁止。src/application に interface Clock を定義し、注入して使う。',
},
{
object: 'Math',
property: 'random',
message:
'乱数生成器への直接アクセスは禁止。' +
'修正手順: src/application にインターフェースを定義し、src/infrastructure で実装して注入する。',
},
],
'no-restricted-globals': ['error',
{
name: 'process',
message:
'環境変数への直接アクセスは禁止。' +
'修正手順: src/presentation で読み取り、値として内側の層に渡す。',
},
],
};
export default tseslint.config(
{ ignores: ['.stryker-tmp/**', 'coverage/**', 'dist/**', 'reports/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/domain/**/*.ts', 'src/application/**/*.ts'],
rules: noExternalResources,
},
{
files: ['**/*.cjs'],
languageOptions: {
sourceType: 'commonjs',
globals: { module: 'writable', require: 'readonly', __dirname: 'readonly' },
},
},
{
files: ['scripts/**/*.mjs'],
languageOptions: {
globals: { console: 'readonly', process: 'readonly' },
},
},
);
ここで大事なのは message です。エラーメッセージは、Claudeが読む修正手順そのものになります。 「禁止です」だけでは直せません。何をどこに定義すればよいかまで書きます。
3-3. .dependency-cruiser.cjs — 層をまたぐimportと、内側2層への外部モジュールのimportを検出する
module.exports = {
forbidden: [
{
name: 'domain-must-be-pure',
severity: 'error',
comment:
'domain層は外部モジュールに依存できない。修正手順: ' +
'(1) 必要な機能をインターフェースとして src/application に定義する ' +
'(2) src/infrastructure で実装する ' +
'(3) src/presentation の組み立て処理で注入する',
from: { path: '^src/domain' },
to: { dependencyTypes: ['npm', 'npm-dev', 'npm-peer', 'npm-optional', 'core'] },
},
{
name: 'application-must-be-pure',
severity: 'error',
comment:
'application層は外部モジュールに依存できない。修正手順: ' +
'その機能をインターフェースとして src/application に定義し、' +
'src/infrastructure で実装して注入する。',
from: { path: '^src/application' },
to: { dependencyTypes: ['npm', 'npm-dev', 'npm-peer', 'npm-optional', 'core'] },
},
{
name: 'domain-must-not-depend-on-outer-layers',
severity: 'error',
comment: 'domain層は他の層をimportできない。依存の向きを逆にする。',
from: { path: '^src/domain' },
to: { path: '^src/(application|infrastructure|presentation)' },
},
{
name: 'application-must-not-depend-on-outer-layers',
severity: 'error',
comment:
'application層は infrastructure と presentation をimportできない。修正手順: ' +
'インターフェースを定義し、実装を注入する形に変更する。',
from: { path: '^src/application' },
to: { path: '^src/(infrastructure|presentation)' },
},
{
name: 'infrastructure-must-not-depend-on-presentation',
severity: 'error',
comment: 'infrastructure層は presentation をimportできない。',
from: { path: '^src/infrastructure' },
to: { path: '^src/presentation' },
},
{
name: 'no-circular',
severity: 'error',
comment: '循環依存がある。共通部分を内側の層に切り出す。',
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: 'node_modules' },
exclude: { path: '(^|/)\\.stryker-tmp/' },
tsPreCompilationDeps: true,
tsConfig: { fileName: 'tsconfig.json' },
},
};
domain-must-be-pure と application-must-be-pure の2つが、ドメインサービスのインターフェースの定義を強制します。内側2層から外部モジュールをimportできない状態にすると、外部リソースを使う手段はインターフェースを定義して注入してもらう以外に残りません。
3-4. scripts/check-ports.mjs — src/application に現れる実装技術の語を検出する
3-2と3-3が「何に触れたか」を見るのに対し、これは「どう名付けたか」を見ます。インターフェース名や引数名に保存手段や通信手段が漏れていると、その層は実装技術を知っていることになります。
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const TARGET = 'src/application';
const FORBIDDEN = [
'sql', 'query', 'join', 'select', 'insert', 'table', 'row', 'column',
'db', 'database', 'orm', 'mongo', 'redis', 'prisma', 'sqlite', 'postgres', 'mysql',
'http', 'rest', 'endpoint', 'axios', 'header', 'cookie', 'session', 'socket',
];
function listFiles(dir) {
if (existsSync(dir) === false) return [];
const out = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) out.push(...listFiles(path));
else if (path.endsWith('.ts')) out.push(path);
}
return out;
}
let failed = false;
for (const path of listFiles(TARGET)) {
readFileSync(path, 'utf8').split('\n').forEach((line, index) => {
const trimmed = line.trimStart();
if (trimmed.startsWith('import') || trimmed.startsWith('//') || trimmed.startsWith('*')) return;
for (const word of FORBIDDEN) {
if (new RegExp(`\\b${word}\\b`, 'i').test(line)) {
console.error(
`${path}:${index + 1} ERROR: 実装技術を示す語 "${word}" が使われています。\n` +
` application層は実装技術を知ってはいけません。\n` +
` 修正手順: 業務上の意味を表す名前に変更してください。` +
` 例: findByIdWithJoin(sql) → findById(id)`
);
failed = true;
}
}
});
}
if (failed) process.exit(1);
console.log('ports: ok');
3-5. stryker.config.json — テストが実装の誤りを検出できるかを測る
ミューテーションテスト(実装をわざと書き換え、テストが失敗するかを見る手法)の設定です。書き換えてもテストが通るなら、そのテストはその箇所を検査していません。
harness:fast には入れません。全変異の分だけテストを回すため実行に数分かかり、編集のたびには実行できないためです。手順11で使います。
{
"testRunner": "vitest",
"coverageAnalysis": "perTest",
"mutate": ["src/domain/**/*.ts", "src/application/**/*.ts"],
"thresholds": { "high": 90, "low": 75, "break": 60 },
"reporters": ["clear-text", "progress"]
}
3-6. knip.json — どこからも使われていないコードを検出する
これも harness:fast には入れません。インターフェースを定義してから実装するまでの間は必ず「未使用」と出るため、編集のたびに走らせると作業が進まなくなります。手順11で使います。
{
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"],
"ignore": [".stryker-tmp/**"]
}
3-7. package.json — 計算的センサーを2つのコマンドに束ねる
既存の内容に以下を追加または置換します。harness:fast は2-2のフックから、harness:slow は手順11から呼ばれます。
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests",
"arch": "depcruise src --config .dependency-cruiser.cjs",
"ports": "node scripts/check-ports.mjs",
"test": "vitest run",
"coverage": "vitest run --coverage",
"mutation": "stryker run",
"dup": "jscpd src --threshold 3",
"dead": "knip",
"harness:fast": "npm run typecheck && npm run lint && npm run arch && npm run ports && npm run test",
"harness:slow": "npm run coverage && npm run mutation && npm run dup && npm run dead"
}
}
&& でつないでいるため、harness:fast は最初の失敗で停止します。 2つ目以降の検査は実行されません。この性質は手順9で効いてきます。
手順4: コードを書かせる前に、計算的センサーが起動することを確認する
この確認を飛ばすと、手順7以降でエラーが出たときに、Claudeが書いたコードの問題なのか設定の問題なのか切り分けられなくなります。
4-1. 仮ファイルを置き、5つの計算的センサーすべてが通ることを確認する
echo 'export {};' > src/index.ts
cat > tests/placeholder.test.ts << 'EOF'
import { it, expect } from 'vitest';
it('placeholder', () => { expect(1).toBe(1); });
EOF
npm run harness:fast
PowerShellの場合。
"export {};" | Out-File -Encoding utf8 src/index.ts
"import { it, expect } from 'vitest';`nit('placeholder', () => { expect(1).toBe(1); });" | Out-File -Encoding utf8 tests/placeholder.test.ts
npm run harness:fast
→ これが出れば正常です。
> tsc --noEmit
> eslint src tests
> depcruise src --config .dependency-cruiser.cjs
✔ no dependency violations found (1 modules, 0 dependencies cruised)
> node scripts/check-ports.mjs
ports: ok
> vitest run
✓ tests/placeholder.test.ts (1 test)
Test Files 1 passed (1)
Tests 1 passed (1)
5つのコマンドがすべて実行され、終了コードが0になります。
確認できたら仮のテストファイルを削除します。src/index.ts は残します。Claudeが起動エントリポイントとして上書きします。
rm tests/placeholder.test.ts
Remove-Item tests/placeholder.test.ts
4-2. 参考: 対象が0件のときに各計算的センサーがどう振る舞うか
harness:fast は最初の失敗で停止するため通常は見えませんが、単独で実行すると次のようになります。
| コマンド |
src/ と tests/ が空のときの結果 |
|---|---|
npm run typecheck |
TS18003 で失敗 |
npm run lint |
成功(対象0件はエラーにならない) |
npm run arch |
成功(0 modules cruised) |
npm run ports |
成功(ports: ok) |
npm run test |
No test files found, exiting with code 1 で失敗 |
対象が1件も無いことをエラーとして扱うのは tsc と vitest の2つだけです。つまり、ファイルが1つも無い状態でも成功する検査が3つあります。 「通った」ことが「検査された」ことを意味するとは限りません。
手順5: ハーネス一式をコミットし、復元できる状態にする
git add -A && git commit -m "harness: ハーネス一式"
以降の手順で元に戻す土台になります。
ここまでで、ハーネスのうちガイドと計算的センサーが揃いました。残る推論的センサーは、計算的センサーが何を取りこぼすかを見たうえで作るため、手順10まで置きます。
ここからは、Claudeに実装させながらハーネスを検証・強化していきます。やることは3つです。
- Claudeに実装させ、ハーネスが機能したかをあなた自身の手で確かめる(手順6〜8)
- 計算的センサーが取りこぼすものを見つけ、センサーを足す(手順9)
- 計算的センサーでは書き表せない範囲に、推論的センサーを足す(手順10)
src/ と tests/ は最後まで自分で編集しません。直すのは常にハーネス側です。
手順6: Claudeに第1版を実装させる
機能1〜5だけを実装させます。機能6(期限)は手順8まで残します。 期限切れの判定には現在時刻が必要になり、domain と application で new Date() を書けないという制約に正面からぶつかります。ハーネスだけを頼りにClaudeが設計を導けるかどうかを見る題材として、あとに取っておきます。
Claude Codeで /model を実行し、Claude Sonnet 5 を選びます。
CLAUDE.md と docs/spec.md と docs/features.md と docs/how-to-test.md を読んで、
docs/features.md の機能1〜5を src/ 配下に実装してください。
テストも tests/ 配下に書いてください。
機能6には手を付けないでください。
手順7: あなた自身の手で、ハーネスが機能したか検証する
Claudeの報告を検証としません。以下はあなた自身が実行します。
npm run harness:fast
echo $?
→ 期待される結果: 終了コードが 0。
続けて、計算的センサーが取りこぼしていないかを目視で確認します。手順4-2で見たとおり、検査が通ったことと検査されたことは同じではありません。
grep -rn "^import" src/domain/ src/application/
→ 期待される結果: 同層内の相対importと、application から domain へのimportだけ。
grep -rn "new Date(\|Date\.now\|Math\.random\|process\.env" src/domain/ src/application/
→ 期待される結果: 0件。
grep -rn "interface" src/application/
→ インターフェース名と引数に、実装技術を示す語が含まれていないことを確認します。scripts/check-ports.mjs の FORBIDDEN に載っていない語も対象にして、自分の目で読みます。
問題がなければコミットします。
git add -A && git commit -m "impl: 第1版"
手順8: 機能6(期限)を実装させ、Claudeが自力でインターフェースを作るか確認する
ここがこの記事の山場です。
機能6を題材に選ぶのは、期限切れの判定に現在時刻が必要になるためです。domain と application で new Date() を書かないと決めてあり、書けば npm run lint が止めます。そのためClaudeは、Clock に相当するインターフェースを src/application に定義する以外に道がなくなります。それを自力でやるかどうかが、この手順で確認したいことです。
仕様(何が正しいか)は docs/spec.md に書いてあり、実装方法(どう作るか)は書いていません。この分担が保たれているかを見る場面でもあります。
/model で Claude Sonnet 5 を選び、次のとおり指示します。
docs/features.md の機能6を実装してください。
補足を足しません。 インターフェースの作り方も、現在時刻の取得方法も指定しません。仕様は docs/spec.md の業務ルール5〜8に書いてあるので、Claudeはそれを読んで実装します。現在時刻をどう取得するかだけは、仕様書にもガイドにも書かれていません。 そこをどう埋めるかが観察の対象です。
口頭で補うと、ガイドの不足をあなたが埋めてしまい、どのルールが検出できていないのか分からなくなります。
8-1. インターフェースが作られたか確認する
作業が終わったら、あなた自身が実行します。
grep -rn "interface" src/application/
→ 第1版に無かったインターフェースが増えていること。名前は Clock でなくてもかまいません。現在時刻を受け取るためのものが src/application に定義されていればよいです。
grep -rn "new Date(\|Date\.now" src/
→ src/infrastructure の1ファイルにだけ現れること。src/domain と src/application に無いこと。
この2つが満たされていれば、Claudeは指示されずに現在時刻を外から受け取る形へ設計を変えたことになります。
インターフェースが増えていないのに期限切れの判定が動いている場合は、どこかに検出されていない経路があります。git diff HEAD -- src/ を読んで、どう実装したかを確認します。
合格の例(これは一例であり、この形になるとは限りません)。
// src/infrastructure/system-clock.ts
import type { Clock } from "../application/ports.js";
export class SystemClock implements Clock {
now(): Date {
return new Date();
}
}
new Date() が現れたのは src/infrastructure の1ファイルだけで、src/domain と src/application には出ていません。Clock インターフェースが src/application に定義され、SystemClock がそれを実装しています。指示していないのにこの設計になっていれば、ハーネスが機能したことになります。
8-2. 作業中の様子を確認する
- Claudeが
npm run harness:fast(計算的センサー)で検証を実行したか - 計算的センサーが違反を検出したかどうか
あなたの対応です。コードは直さず、ガイドか計算的センサーを直します。
| 状況 | 対応 | 直すファイル |
|---|---|---|
| 計算的センサーは検出したが直せなかった |
comment や message の文言を具体化する |
eslint.config.js、.dependency-cruiser.cjs
|
| どの計算的センサーも検出できなかった | 新しい計算的センサーを追加する | 手順9-2の表を参照 |
| インターフェース名に技術を示す語が残った |
FORBIDDEN に単語を追加する |
scripts/check-ports.mjs |
| Claudeが規約自体を読み違えていた | 規約の記述を明確にする | CLAUDE.md |
| 許可されていないコマンドで作業が止まった |
allow に追加する |
.claude/settings.json |
確認が済んだらコミットします。
git add -A && git commit -m "impl: 機能6"
手順9: わざと違反コードを書かせ、計算的センサーが見逃すものを探して追加する
手順8までは、Claudeが規約を守ろうとした結果を見てきました。ここでは逆に、守る気がない相手にハーネスが通用するかを試します。
src/ 配下に、CLAUDE.md の依存ルールと外部リソース禁止ルールに違反するコードを
3種類、わざと書いてください。
違反の種類は、あなたが「検出されにくい」と思うものを選んでください。
書いたら npm run harness:fast を実行し、検出されたかどうかを報告してください。
この作業に限り、CLAUDE.md の「すべて成功するまで作業を終えない」は適用しません。
検査が失敗しても修正せず、3種類とも書いたまま残して報告を返してください。
最後の2行は必ず入れます。ファイル編集のたびにフックが harness:fast を走らせるため、これが無いとClaudeは自分で違反コードを消してしまい、あなたが仕分けるものが残りません。
9-1. 見逃されたものがあるか確認する
Claudeの報告を鵜呑みにしません。 あなた自身が実行します。
npm run harness:fast
echo $?
→ 1件でも検出されていれば、終了コードは 0 以外になります。
ただし harness:fast は && でつないであるため、最初の失敗で停止します。 1つ目を検出した時点で残りの検査は走っておらず、3件書かせても表示されるエラーは1件分ということが起こります。仕分けの前に、5つを個別に実行してすべての結果を集めます。
npm run typecheck; npm run lint; npm run arch; npm run ports; npm run test
; でつなぐと、途中が失敗しても最後まで実行されます。
次に、Claudeが何を書いたかを確認します。
git diff HEAD -- src/
差分に出てくるものがすべて違反とは限りません。 違反コードを成立させるために追加した型定義やimport、その他の無関係な変更も混ざります。これらは計算的センサーが検出しなくて当然です。
そこで、差分を次の3つに仕分けます。判断するのはあなたであり、Claudeの報告ではありません。
| 仕分け | 内容 |
|---|---|
| A. 違反であり、検出された | 差分にあり、上の個別実行のエラーにも出ている |
| B. 違反だが、検出されていない | 差分にあるが、エラーに出ていない |
| C. 違反ではない | 差分にあるが、CLAUDE.md のルールに照らして違反ではない |
Bだけが対処の対象です。Cは何もしません。
仕分けの基準は CLAUDE.md の「依存ルール」と「外部リソースへの直接アクセスの禁止」です。Claudeが「違反を3種類書いた」と報告していても、実際にはCだったということがあります。1件ずつ照らして判断します。
5つとも成功していた場合は、AもBも区別する以前に計算的センサーが1件も反応していません。差分の中に違反が1つでもあれば、それはすべてBです。
9-2. 計算的センサーを追加する
Bに仕分けたものだけを対象にします。違反の種類に応じて、次のファイルを編集します。src/ は編集しません。
| 見逃された違反 | 編集するファイル | 追加するもの |
|---|---|---|
domain や application からの不正なimport |
.dependency-cruiser.cjs |
forbidden に新しいルールを1つ追加する |
| 現在時刻・乱数・環境変数など、importを伴わない外部リソースへのアクセス | eslint.config.js |
no-restricted-syntax、no-restricted-properties、no-restricted-globals のいずれかに項目を追加する |
| インターフェース名に混ざった実装技術の語 | scripts/check-ports.mjs |
FORBIDDEN に単語を追加する |
| 上のどれにも当てはまらない |
scripts/ に新しい検査スクリプトを作る |
作ったうえで package.json の harness:fast にコマンドを追加する |
追加したら、違反コードを残したまま実行します。
npm run harness:fast
echo $?
→ Bに仕分けた違反が検出され、終了コードが 0 以外になること。ここで検出されなければ、追加した計算的センサーが機能していません。 ルールを書き直して再度実行します。
このとき、Cに仕分けたコードがエラーに出ていないことも確認します。出ていれば、追加したルールが広すぎて正しいコードまで弾いています。
9-3. 違反コードを消す
計算的センサーが検出することを確認できたら、Claudeに書かせた違反コードを破棄します。git checkout はClaudeに禁止しているので、あなたが実行します。
git checkout -- src/
src/ 配下の未コミットの変更をすべて捨て、直前のコミットの状態に戻します。追加した計算的センサーは src/ の外にあるので消えません。
git status
→ src/ の変更が消え、.dependency-cruiser.cjs など計算的センサー側の変更だけが残っていること。
npm run harness:fast
echo $?
→ 終了コードが 0 であること。追加した計算的センサーが、正しいコードまで誤って弾いていないかの確認になります。
git add -A && git commit -m "sensor: 検出できていなかったルールを追加"
手順10: 推論的センサーを作り、計算的センサーでは判定できない問題を検出する
手順9までで作ったのは計算的センサーだけです。計算的センサーが判定できるのは、構文の形と名前の一致に還元できるものに限られます。次のようなものは書き表せません。
- 命名が業務上の意味を表しているか
- 抽象化の粒度が適切か
-
docs/spec.mdに無い機能を追加していないか - テストの期待値が仕様から導かれているか、実装から写されたものか
npm run harness:fast が全部緑の状態から始めて、それでも指摘が出ることを確認します。 ここで出た指摘が、計算的センサーの届いていなかった範囲です。
10-1. レビュー用のカスタムコマンドを作る
.claude/commands/architecture-review.md を作成します。.claude/** はClaudeからの編集を禁止しているので、あなたが作ります。
あなたはこのリポジトリのアーキテクチャレビュー担当です。
CLAUDE.md と docs/spec.md を読み、src/ 配下について次の観点で確認してください。
機械的な検査(npm run harness:fast)が通っている前提で、
意味の判断が必要な点だけを報告してください。
1. `src/application` のインターフェースの粒度は適切か。1つのインターフェースに責務を詰め込みすぎていないか
2. `src/application` のインターフェースの名前と引数に、実装技術を前提とした表現が残っていないか
(scripts/check-ports.mjs の語彙リストに無い技術名も対象)
3. domain層のクラスに、業務ルールではなく技術的都合のロジックが混ざっていないか
4. docs/spec.md に書かれていない機能や挙動を追加していないか
5. テストが、実装をそのまま写しただけの検証になっていないか
各指摘について「該当ファイル・行」「なぜ問題か」「どう直すか」の3点を書いてください。
問題がない観点については「問題なし」とだけ書いてください。
作成したらClaude Codeを再起動します。カスタムコマンドはセッション開始時に読み込まれるため、起動中に作ってもスラッシュコマンドの一覧に出てきません。
再起動後、/model で Claude Opus 5 に切り替え、/architecture-review を実行します。
推論的センサーは harness:fast に組み込みません。実行が遅く、同じコードでも結果がぶれるためです。節目で1回だけ実行します。
10-2. 指摘を4つに仕分ける
出てきた指摘をそのままClaudeに直させません。推論的センサーは間違えることがあるため、まずあなたが1件ずつ判定します。
| 仕分け | 内容 | 対応 |
|---|---|---|
| A. 指摘が正しく、コードを直せばよい | 実装が規約に反している | Claudeに修正させる |
| B. 指摘が正しく、仕様書を直す必要がある |
docs/spec.md の記述が実態と合っていない |
あなたが docs/spec.md を更新する |
| C. 指摘が正しく、計算的センサーで検出できる | 今後も同じ問題が起きる | 計算的センサーを追加する。手順9-2と同じ作業 |
| D. 指摘が誤っている | 規約に反していない、または前提を誤解している | 何もしない。ただし後述の確認をする |
Cに該当するかどうかは、指摘された内容が構文の形か名前の一致に還元できるかで判断します。できるなら計算的センサーに移せます。できないなら、次回も推論的センサーに頼るしかありません。
10-3. Bの対応 — 仕様書を更新する
Claudeには docs/spec.md を書き換えさせません。 権限設定で Edit(docs/**) を禁止しているのは、仕様を決めるのが人間だからです。
Bが発生するのは、仕様書が決めていない挙動をClaudeが決めてしまった場合です。典型は次のようなものになります。
-
docs/spec.mdは「空白文字だけのタイトルは受け付けない」としか書いていないが、Claudeがタイトル前後の空白を除去して保存する実装にした - IDの採番方式が「自動で採番する」としか書かれておらず、Claudeが特定の形式を決めた
- 過去の日付を
dueDateに指定できるかが書かれておらず、Claudeが拒否する実装にした
いずれも仕様書に根拠がありません。推論的センサーは「docs/spec.md に無い挙動がある」と指摘し、この判定は形式上正しいです。決めていなかったのはあなただからです。
どちらかを選びます。
| 選択 | 作業 |
|---|---|
| その挙動を採用する | あなたが docs/spec.md に業務ルールとして追記する。そのうえでClaudeにテストを追加させる |
| その挙動をやめる | Claudeに該当コードとテストを削除させる |
前者を選ぶ場合、追記が終わるまでClaudeに次の作業をさせません。仕様書が古いまま実装を進めると、そこから導いたテストも仕様と食い違います。
10-4. Dの対応 — 誤りの原因を確認する
指摘が誤っていた場合、そのまま無視せず原因を見ます。次の2つが考えられます。
-
CLAUDE.mdやdocs/spec.mdの記述が曖昧で、推論的センサーが誤読した — 記述を明確にする。同じ誤読は次回も起きる -
レビュー用プロンプトの観点がずれている —
.claude/commands/architecture-review.mdの観点を書き直す
どちらでもなく、単にセンサーがぶれただけということもあります。その場合は何もしません。
10-5. 修正後に確認する
AとBの対応が終わったら、あなた自身が実行します。
npm run harness:fast
echo $?
→ 0 であること。
git diff --stat HEAD
→ Cで計算的センサーを追加した場合、その設定ファイルが差分に含まれていること。
git add -A && git commit -m "review: 推論的センサーの指摘に対応"
10-6. この手順で分かること
推論的センサーも見逃します。 指摘が出なかったからといって、問題が無いことにはなりません。計算的センサーだけの状態より検査の範囲が広がっただけで、届かない範囲は依然として残ります。
そして仕分けの結果から、あなたのハーネスの状態が読み取れます。
- Cが多い → 計算的センサーで塞げるものを塞げていない。手順9の作業が足りていない
- Bが多い → 人間の決定が文書に落ちていない。決めるべきことを決めないまま実装させている
- Dが多い → ガイドの記述が曖昧か、レビュー用プロンプトの観点がずれている
手順11: 仕上げの検査を実行し、テストを信用してよいか判定する
ここまでの検査は「コードが規約を守っているか」を見てきました。最後に、テストとコード全体の状態を測ります。3-7で harness:slow にまとめた4つを実行します。
npm run harness:slow
| 検査 | 見るもの | 低い/多いときの意味 |
|---|---|---|
coverage |
テストが通った行の割合 | 検査していない箇所がある |
mutation |
実装を書き換えたときテストが失敗するか | 通っているだけで、何も検査していないテストがある |
dup |
重複したコード | 同じ業務ルールが複数箇所に散っている可能性がある |
dead |
どこからも使われていないコード | 仕様に無いものを作った、または消し忘れた |
ミューテーションスコアが低い場合、結果をClaudeに渡します。
npm run mutation の結果、task.ts のミューテーションスコアが45%でした。
生き残った変異を確認し、それを検出できるテストケースを追加してください。
docs/spec.md の業務ルールに対応するテストが不足していないか確認してください。
ミューテーションスコアが測るのはテストと実装の結びつきであって、仕様との一致ではありません。実装が仕様と食い違っていて、テストがその実装から期待値を写していた場合もスコアは高く出ます。仕様との一致は docs/spec.md と突き合わせて人間が確認します。
git add -A && git commit -m "test: 仕上げの検査に対応"
完了チェックリスト
-
src/とtests/を一度も自分の手で編集していない -
npm run harness:fastの終了コードが0 -
src/applicationのインターフェースを読み、メソッド名と引数に保存先やHTTPを前提とした表現が無い -
src/domainとsrc/applicationにnew Date()、Math.random()、process.envがない - 機能6で、指示していないのにドメインサービスのインターフェースが定義された
- わざと入れた3種類の違反がすべて自動検出されるところまで、計算的センサーを追加した
- 推論的センサーが、計算的センサーでは検出できない問題を1つ以上指摘した
- ミューテーションスコアが60%以上
おわりに
やってみると、手順8で「指示していないのに Clock インターフェースが生えている」瞬間がいちばん面白いと思います。あれは行儀よく振る舞った結果ではなく、それ以外に通る道が無かった結果です。規約を守らせるとはそういうことだ、という体験がこの記事の中心です。
3つ、持ち帰ってほしいことがあります。
1. 「守ってほしい」を「守るしかない」に変換する
CLAUDE.md に書いた規約は、破られても誰も気づきません。同じ規約を eslint.config.js と .dependency-cruiser.cjs に書き直した瞬間、破ったコードは成立しなくなります。文書と設定は役割が違い、片方だけでは足りません。
2. エラーメッセージは修正手順である
人間向けのlint設定なら「禁止です」で足ります。AIに自己修正させるなら、「どこに何を定義して、どう注入するか」まで書きます。この記事の設定ファイルで message と comment が長いのはそのためです。メッセージの質が、そのまま自己修正の成功率になります。
3. ハーネスは完成しない
計算的センサーは構文と名前しか見られません。推論的センサーは意味を見られますが、ぶれるし見逃します。手順9で見逃しを見つけ、手順10でさらに外側の見逃しを見つけましたが、その外側はまだ残っています。ハーネスは「完成させるもの」ではなく「見逃すたびに1枚ずつ足していくもの」です。
そして作業の中身が変わります。コードを書く時間はほぼゼロになり、代わりに「制約をどう表現するか」「仕様として何を決めておくか」に時間を使うことになります。手順10の仕分けでBが多かった人は、決めるべきことを決めないまま実装させていた、ということです。この気づきだけでも、やる価値があると思います。
参考
-
Claude Code settings(公式ドキュメント) —
permissionsとhooksの設定 - dependency-cruiser — 依存関係の検証と可視化
- Stryker Mutator — ミューテーションテスト
- Knip — 未使用コード・未使用依存の検出
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp