
はじめに
ADR(Architecture Decision Record)を書いたのに、半年後の Pull Request で前提が当たり前のように破られている。
そんな経験はありませんか。
実際にチーム開発に入っていると、私自身もこの「ADR の侵食」を何度も目にしてきました。
正直なところ、明文化しただけでは決めた設計判断は守られません。
本記事では、ADR で決めた禁止事項を pre-commit と CI で機械的に検出する実装パターンを、git merge-base ベースの差分スキャンスクリプトつきで紹介します。
実際にこの仕組みを導入したリポジトリでは、レビュアーが ADR の存在を毎回意識しなくても、違反 PR は CI の早い段階で機械的に検出される運用に切り替わりました。
こんな人におすすめ
- ADR を運用しているが、決めたルールが時間とともに侵食されて困っている方
- 「新規コードでこのカラムは使わない」のような禁止事項を、機械的に守らせたい方
- GitHub Actions で PR の差分だけを対象にチェックを走らせるスクリプト例を探している方
- アーキテクチャ判断のレビュー負担を、レビュアーから機械に移したい技術リードの方
なぜ ADR は侵食されるのか
ADR を運用していると、よくある「侵食」のパターンは次のようなものです。
- 当初は「
plan_tierカラムは新規コードでは使わない」と決めた - 数ヶ月後、別のメンバーが急ぎの修正で素直に
user.plan_tierを参照してしまう - レビュアーは ADR の細部まで覚えていないので気づかず、そのままマージされる
- 半年後、似た参照が増えて「結局 plan_tier は現役」になる
私が見てきたいくつかのケースでも、ADR を書いてから半年程度の間に違反参照が静かに積み上がる現象は珍しくありませんでした。
これを防ぐのに「全 PR で必ず ADR を読み返す」のは現実的ではありません。
代わりに、機械が ADR の禁止事項を自動でチェックする仕組みを入れる方が効きます。
設計の3層構造
ADR を守る仕組みは、大きく3層で組み立てます。
flowchart TD
A[ADR 本文<br/>禁止事項を明示] --> B[静的チェックスクリプト<br/>差分から検出]
B --> C[CI / pre-commit<br/>自動実行]
A --> D[PR テンプレート<br/>自己申告チェックリスト]
D --> E[PR レビュー]
C --> E
E --> F{違反検出?}
F -->|あり| G[マージブロック]
F -->|なし| H[マージ可]
人間(ADR 本文・PR テンプレート)と機械(チェックスクリプト・CI)の二重防御で、レビュアーの記憶に頼らない構造を作ります。
1. ADR 本文に「禁止事項」を一項立てる
ADR の Decision セクションの中に、次のような禁止事項を明示します。
## Decision
### existing users.plan_tier は段階的に非推奨化する
- 新規コードで `plan_tier` を参照しない
- レガシーブリッジ(特定の billing 系ファイル)の中でのみ参照可
ここで「具体的にどの識別子・パターンが禁止か」を識別子レベルまで落とすのが鍵です。
「権限ロジックを分離する」のような抽象的な記述だけでは、機械化できません。
2. 静的チェックスクリプト(Node + git diff)
PR で追加された変更だけを対象に、特定の識別子の混入を検出する Node スクリプトを書きます。
// scripts/check-adr.mjs
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const repoRoot = process.cwd()
// レガシーブリッジで参照を許可するパス(許可リスト)
const legacyAllowedPaths = [
/^migrations\/0020_stripe_billing\.sql$/,
/^worker\/routes\/billing\.ts$/,
/^worker\/services\/billing\.ts$/,
]
const monitoredExtensions = new Set([
'.ts', '.tsx', '.js', '.mjs', '.cjs',
'.json', '.sql', '.yaml', '.yml',
])
function runGit(args) {
return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim()
}
function resolveBaseRef() {
const candidates = []
if (process.env.GITHUB_BASE_REF) {
candidates.push(`origin/${process.env.GITHUB_BASE_REF}`)
}
if (process.env.CI_BASE_REF) candidates.push(process.env.CI_BASE_REF)
candidates.push('origin/main', 'main')
for (const candidate of candidates) {
try { return runGit(['merge-base', 'HEAD', candidate]) } catch { /* try next */ }
}
return runGit(['rev-parse', 'HEAD~1']) // 最後のフォールバック
}
const baseRef = resolveBaseRef()
const range = `${baseRef}...HEAD`
const changed = runGit(['diff', '--name-only', '--diff-filter=ACMRTUXB', range])
.split('\n').filter(Boolean)
const violations = []
for (const filePath of changed) {
const ext = path.extname(filePath).toLowerCase()
if (!monitoredExtensions.has(ext)) continue
if (legacyAllowedPaths.some((p) => p.test(filePath))) continue
const fullPath = path.join(repoRoot, filePath)
if (!fs.existsSync(fullPath)) continue
const content = fs.readFileSync(fullPath, 'utf8')
if (/\bplan_tier\b/.test(content)) violations.push(filePath)
}
if (violations.length > 0) {
console.error('ADR 違反: 以下のファイルで plan_tier が新規参照されています')
for (const v of violations) console.error(' -', v)
process.exit(1)
}
console.log('ADR check passed.')
このスクリプトの設計判断は3つあります。
(a) git merge-base HEAD origin/main で分岐点を取る
ブランチが分岐した地点から HEAD までの変更だけを対象にすると、既存の合法な plan_tier 参照は触らずに済みます。
(b) base ref のフォールバックを複数段で書く
GITHUB_BASE_REF は GitHub Actions の pull_request イベントでしか定まりません。
push 駆動 CI やローカル実行のために、origin/main → main → HEAD~1 と段階的にフォールバックします。
© 拡張子フィルタと許可リスト
監視拡張子を絞ると無駄なスキャンが減ります。
許可リストは正規表現の配列で、レガシーブリッジ内のファイルを明示的に許可します。
3. CI に専用ジョブとして組み込む
adr-guard を独立ジョブにし、他のジョブの needs: にすると、ADR 違反があれば後続のジョブを走らせる前に止められます。
# .github/workflows/ci.yml
jobs:
adr-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # ★ git merge-base 用に履歴を全部取る
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: node scripts/check-adr.mjs
lint:
runs-on: ubuntu-latest
needs: adr-guard # ADR ガードを通過しないと走らない
steps:
- uses: actions/checkout@v4
# ... lint が git 履歴を必要とする場合のみ fetch-depth: 0 を追加
adr-guard ジョブには fetch-depth: 0 が必須です。
デフォルトの shallow clone のままだと git merge-base が動かず、ジョブが fatal: Not a valid object name のような git 由来のエラーで落ちます。
私自身、最初の実装でこの設定を忘れ、CI の不可解なエラーに30分悩まされた経験があります。
4. PR テンプレートにチェックリストを入れる
機械チェックに加えて、PR テンプレートに自己申告のチェックリストを入れると、二重防御になります。
<!-- .github/pull_request_template.md -->
## ADR XXXX 準拠
- [ ] 新規コードで plan_tier を追加していない
- [ ] 影響範囲は legacy bridge の中に閉じている
- [ ] `npm run check:adr-XXXX` がローカルで通ることを確認した
このチェックリストは、PR レビュアーに「ADR の存在を思い出させる」役割も持ちます。
つまづきやすいポイント
fetch-depth: 0 忘れ
GitHub Actions のデフォルトは shallow clone(履歴1コミットのみ)です。
git merge-base で分岐点を取る ADR ガードジョブには履歴の全取得が必要なので、adr-guard ジョブに必ず fetch-depth: 0 を指定してください。
needs: adr-guard で繋いでいる lint や build などの後続ジョブは、git 履歴を必要とする処理(例: 履歴ベースのリンタ、コミットメタ参照)が含まれていなければ、fetch-depth: 0 は不要です。プロジェクト側で必要に応じて追加してください。
エラーメッセージは fatal: Not a valid object name のような git 由来の分かりにくいものになりがちで、原因特定に時間を取られます。
既存の合法な参照まで違反扱いになる
「全リポジトリ」を毎回スキャンする実装にすると、PR 以前から存在する plan_tier 参照まで弾かれます。
git diff で PR の追加・変更分だけ見るのが前提です。
git merge-base で分岐点を特定しないと、この問題に気づかずに導入してしまうケースが多いので注意してください。
許可リストを広く取りすぎる
/^worker\// のような広い正規表現は事故の元です。
ファイル名単位(/^worker\/routes\/billing\.ts$/)で書く方が安全です。
「とりあえず広めに許可しておこう」と書くと、半年後に何が許可されているかが分からなくなります。
導入後の効果
導入前後で見える変化は、おおむね次のような方向性になります(プロジェクト規模やチーム文化により振れ幅があるので、参考値として読んでください)。
- 違反検出までの時間: 数週間後の発見 → CI 実行時点で即時検出
- レビュアーの負担: ADR の細部を毎回読み返す → 機械検出された違反だけ見ればよい
- ADR の遵守率: レビュアーの記憶頼みで揺れる → 違反コードはそもそもマージされない構造に
- チームへの説明工数: 定期リマインダで再周知する手間が不要に
何より大きいのは、レビュアーが**「ADR の存在を毎回意識しなくて良い」**ことです。
人間が忘れても機械が覚えていてくれる、という心理的安全性が運用を継続させます。
次にやりたいこと
- pre-commit フックでも同じスクリプトを走らせる(ローカルで早く落ちる方が体験が良いと考えています)
- 違反検出時のエラーメッセージに ADR の URL を埋め込む
- 違反コードの自動修正提案を AI で生成するバッチ化
まとめ
ADR を「決めた」あとで侵食を防ぐには、次の3つを揃えます。
- ADR 本文に禁止事項を識別子レベルで書く
git merge-baseベースの静的チェックスクリプトを CI と pre-commit で実行- PR テンプレートのチェックリストで自己申告を仕込む
機械が読めるルールに落としきると、レビュアーの認知負担が下がり、ADR の効力が長続きします。
スクリプトと CI 設定は半日〜1日程度の作業で組めるので、初期投資は限定的です。その後の運用負荷の軽減を考えると、ADR を運用しているチームには十分検討に値する仕組みだと感じます。


