はじめに
シリーズ第3回。①②(助言)の続きです。
この記事では、ハーネスの中核「やったら止める」決定論的な強制を、動くコードで紹介します。①②の助言を、破れない約束に変える層です。
- 依存方向を機械検査する import-linter
- コミット前に止める pre-commit
- AIの編集を“その場で”拒否する Claude のフック
- 最後の砦の CI
📦 ソースコード:https://github.com/NeoSolleil/aim-trainer/tree/harness-only
(harness-onlyブランチ。mainは今後の機能開発で変わるため、記事と一致する状態を固定したブランチです)
↓mainブランチはこちらです
なぜ「機械強制」が要るのか
第2回までの CLAUDE.md と .claude/rules/ は助言でした。助言は破れます。「絶対に起きてはならないこと」(設計の根幹を壊す変更)は、人やAIの善意ではなく、機械で止めるしかありません。
そこで、同じ規約を3つの関門で多層に守ります。早い順に:
- 編集の瞬間 … Claude Code のフック(AIのツール使用を直前に拒否)
- コミットの瞬間 … pre-commit(ローカル)
- push の瞬間 … GitHub Actions CI(リモート)
同じ違反を三重に塞ぐことで、どこかをすり抜けても次で止まります。
A. import-linter — 依存方向を機械検査する
本プロジェクトの backend は Clean Architecture で、依存は内向き(外側→内側)のみという根幹ルールがあります。これを文章でなく機械検査にします。
import-linter は、Pythonの import 関係が契約に反していないかをチェックするツールです。pyproject.toml にレイヤ契約を書きます。
[[tool.importlinter.contracts]]
name = "Clean Architecture layer dependencies"
type = "layers"
layers = [
"app.infrastructure", # 外
"app.adapters",
"app.application",
"app.domain", # 内
]
これで「内側(domain)が外側(infrastructure 等)を import したら検査が失敗」します。
矢印は許される依存の向き(外→内)。domain は誰にも依存せず、内側ほど安定した中核になります。
さらに「domain はそもそもフレームワークを import しない(純粋に保つ)」を forbidden 契約で縛りました。
[[tool.importlinter.contracts]]
name = "domain must be framework-free"
type = "forbidden"
source_modules = ["app.domain"]
forbidden_modules = ["fastapi", "sqlalchemy", "pydantic", "starlette", "uvicorn"]
「効くこと」を確認する(負のテスト)
仕組みは、実際に違反を検出できて初めて意味があります。試しに domain に import sqlalchemy を一行入れて検査を走らせると:
domain must be framework-free BROKEN
app.domain is not allowed to import sqlalchemy
ちゃんと失敗しました。挿入した行を消せば通ります。「書いたら止まる」を目で確認してから本採用する——この一手間が、ハーネスを“飾り”にしないコツです。
B. pre-commit — コミット前にまとめて止める
pre-commit は、git commit の直前に任意のチェックを自動実行する仕組みです。失敗したらコミットが中断されます。
backend は ruff(lint/整形)・pyright(型)・import-linter(依存方向)・xenon(複雑度)、frontend は ESLint・Prettier・tsc を走らせています。
「直接SQL禁止」を grep で機械化する
「ORM経由で書く(生SQLを書かない)」という規約は、import の有無では判定できません(“どう書いたか”の問題)。そこで、生SQLの入口(text(...) / exec_driver_sql)を grep で検出して落とす小さなスクリプトを pre-commit に足しました。
# backend/app 配下に生SQLの入口があれば失敗させる(抜粋)
if grep -rnE '(^|[^A-Za-z0-9_.])(text|exec_driver_sql)[[:space:]]*\(' \
backend/app --include='*.py'; then
echo "ERROR: 直接SQLを検出。ORM経由で書いてください。" >&2
exit 1
fi
「機械で判定できない規約は、判定できる形に翻訳する」——完璧でなくても、主要な違反を確実に止められれば十分です。
C. Claude Code のフック — AIの編集を“その場で”拒否する
ここが目玉です。Claude Code には PreToolUse フックという仕組みがあり、AIがツール(ファイル編集やコマンド実行)を使う直前に介入して、許可・拒否を返せます。
なぜ強力かというと、pre-commit や CI は「コミット後/push後」に止めますが、フックは編集しようとした瞬間に止められるからです。AIが間違ったコードを書く前に差し戻せます。
設定は .claude/settings.json に、対象ツールと実行スクリプトを書きます。
{
"hooks": {
"PreToolUse": [
{ "matcher": "Write|Edit",
"hooks": [{ "type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_architecture.py\"" }] }
]
}
}
スクリプト側は、編集先のパスと書き込む内容を受け取り、違反なら拒否のJSONを返します(抜粋)。
# 編集先が domain/application で、禁止フレームワークを import していたら拒否
if in_domain_or_app and FORBIDDEN_IMPORT.search(content):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "domain/application は FW を import できません",
}}))
これで、AIが backend/app/domain/foo.py に from sqlalchemy import ... を書こうとした瞬間、その編集自体が拒否されます。
実装上の注意:フックが壊れても作業を止めないよう、スクリプトは例外時に「許可」を返す設計(fail-open)にしました。後段の pre-commit と CI が最後の砦として残るので、ここで全部止める必要はありません。
ついでに、git commit --no-verify(pre-commit を迂回するオプション)も別のフックで禁止しました。せっかくの関門を、AI自身に飛び越えさせないためです。
D. GitHub Actions CI — 最後の砦
ローカルの pre-commit はうっかり無効化もできます。最後の砦として、push / PR 時に GitHub Actions で同じチェックを再実行します。ローカルをすり抜けても、リモートで止まります。public リポジトリなので Actions は無料・分数無制限で回せます。
設計思想:助言と強制の書き分け+多層防御
-
書き分け:「これは助言」「これは機械強制」を曖昧にしない。
.claude/rules/には「この項目は import-linter で強制」と注記し、効かないものを“強制”と偽らない。 - 多層防御:同じ規約を、編集時(フック)・コミット時(pre-commit)・push時(CI)で三重に。
まとめ
「お願い」を「破れない約束」に変えるのが、この③の層です。import-linter で依存方向を、grep で生SQLを、Claude のフックで“編集の瞬間”を、CI で最後を——多層で塞ぎました。
次の第4回は、この土台の上で何を・どう作るか=仕様駆動のパイプライン(発見→仕様→設計→分解→実装)に進みます。