TL;DR
- CI が緑になる経路は 2 つあります。違反が無いから緑と、検査が動いていないから緑です。後者は PR 画面上、前者と完全に同じ見た目をします。
- このブログのリポジトリで、2026-09-04 に記事の執筆を再開してから 4 日間で、「書いたのに効いていなかったガード」が 7 種類出ました。すべて PR / issue として実在します(
社内データ)。 - そのうち 2 件は導入から 139 日 / 113 日(2026-04-20 と 2026-05-16 に入れたもの)気づかれていませんでした。存在するだけのガードは、静かに何も守らないまま残ります。
- 確かめ方は 1 つだけです。わざと壊した入力を 1 回通して、非 0 で落ちることを見る。本記事にそのまま動くスクリプトを載せました。実行すると、修正前のガードが
exit 0(見逃し)、修正後がexit 1(検出)になります。 - ただしその二面検証も摩耗します。
validate_frontmatter.pyは検出分岐が 9 個ありますが、壊した入力のテストがあるのは 4 個です。今回直した分岐には、修正後も 0 件でした(2026-09-07 時点、社内データ)。 - 記事の作業を止めて仕組みを整えた時間の中身も測りました。整備日に追加された 1,018 行のうち 79.7% は AI に読ませる指示文で、機械が実行するファイルは 3 つだけでした。指示文は CI で落ちません。
はじめに:緑の 2 つの意味
このブログは AI エージェントに記事を書かせて運用しています。エージェントは指示に従うので、ガードレールを増やすほど品質が安定する——という前提で、scripts/ 配下に検査スクリプトを積み、spec/ に契約を書き、.agents/ にスキル定義を置いてきました。
その前提には穴があります。ガードが「存在すること」と「効いていること」は別物で、CI の緑はその 2 つを区別しません。違反が 0 件でも緑、検査が 1 行も走っていなくても緑です。
この記事は一般論ではなく、このリポジトリで実際に何が起きたかの記録です。2026-09-04 に記事の執筆を再開した直後から、過去に入れたガードの欠陥が続けて表面化しました。それを壊れ方で分類し、どう確かめれば見つかるかまでを書きます。
一般論として「テストを書こう」で終わらせないために、すべての事例に PR 番号と、手元で再現できるコマンドを付けます。逆に、組織としてどう振る舞うべきかという提言はしません。これは提言ではなく、1 リポジトリの記録です。
なお、「宣言したガバナンスと実際に効いているガバナンスがずれる」という上位の問題は AIガバナンスが効かない4類型と検証手順 で扱っています。そちらが権限設定や規約を含むガバナンス全体の話なのに対し、本記事はそのうち「ガードスクリプトと CI 配線」に絞って、壊れ方を実物で分類したものです。重なるのは「ガードの未実行」という 1 類型で、本記事はそれを 7 つに割っています。
1. 「止まっていた期間」を 2 つの時計で測ったら食い違った
最初に、この記事を書くきっかけになった測定そのものを検算します。結論から言うと、最初の測定は間違っていました。
記事の frontmatter にある date で並べると、公開の空白期間はこう出ます。
# frontmatter(先頭の --- から次の --- まで)の date だけを拾う
for f in content/posts/*/index.md; do
awk '/^---$/{n++; next} n==1 && /^date:/{gsub(/"/,"",$2); print substr($2,1,10); exit}' "$f"
done | sort > /tmp/dates.txt
wc -l < /tmp/dates.txt # => 215
cut -c1-7 /tmp/dates.txt | uniq -c | tail -3
# 31 2026-07
# 7 2026-08
# 4 2026-09
出力例は本記事を追加する前の基準コミット df1098b(2026-09-07)のものです。この記事自体が 1 本増えるので、マージ後に同じコマンドを流すと 216 本 / 2026-09 が 5 本になります。
grep -h '^date:' content/posts/*/index.md で済ませようとして、最初は 1 本多く数えました。eeat-for-ai-search/index.md は本文中にも行頭 date: を含んでいて、frontmatter を区切らずに拾うと二重に数えられます。この記事のためのコマンドですら、1 回目は間違っていました。
記事総数 215 本、最古 2026-02-05、最新 2026-09-07。月別に見ると 7 月の 31 本から 8 月は 7 本へ落ち、2026-08-12 → 2026-09-04 の 23 日間が最長の空白でした(社内データ)。14 日以上の空白は履歴上 2 回だけで、もう 1 回は 2026-02-22 → 2026-03-08 の 14 日です。
ここまでは合っています。問題は次の一手です。「記事は止まっていたが、リポジトリは動いていたはずだ」と考えて、同じ期間のコミットを数えました。
git log --since=2026-08-12 --until=2026-09-05 --oneline | wc -l # => 114
114 コミット。**「記事を書く代わりに、記事を書く仕組みを作り直していた 23 日間」**という綺麗な話になります。しかしこの数字は、日付ごとの分布を見ないと意味が取れません。
git log --since=2026-07-01 --format='%ad' --date=short | sort | uniq -c
# 4 2026-07-09
# 104 2026-09-02
# 5 2026-09-04
# 6 2026-09-05
# 9 2026-09-06
# 6 2026-09-07
# ↑ 基準は `df1098b`。以後のコミットで最終行は増えます
8 月のコミットは 0 件でした。114 のうち 104 件は 2026-09-02 の 1 日に入っています。しかもその 104 件は 00:06 から 06:05 までの約 6 時間に収まっていて、実体は PR #426 / #427 / #428 の 3 本です(社内データ)。
git log --after=2026-09-01 --before=2026-09-03 --format='%h %ad %s' --date=format:'%H:%M' \
| grep -E '#42[0-9]'
# b77f581 06:05 Merge pull request #428 from s977043/feat/article-maker-checker-separation
# 484a5e7 01:21 Merge pull request #427 from s977043/feat/article-artifact-ssot
# f975582 00:49 feat(article): 5フェーズのワークフローモデルを導入 (#426)
さらに、frontmatter で 2026-08-12 になっている記事の実際のコミットを引くと、こうなります。
git log -1 --format='%h %ad %s' --date=short -- content/posts/prisma-with-claude-code/index.md
# => 84f13a0 2026-09-04 feat(content): Prisma × Claude Code 記事追加(claude-codeシリーズ order=7) (#414)
「2026-08-12 の記事」は 2026-09-04 にコミットされています。 frontmatter の date は公開日として人間(またはエージェント)が書き込む値で、作業がいつ行われたかの観測値ではありません。
2 つの時計を並べると、こうなります。
| 時計 | 空白期間 | 長さ | 何を測っているか |
|---|---|---|---|
frontmatter の date | 2026-08-12 → 2026-09-04 | 23 日 | 記事に書き込まれた公開日 |
コミット日(git log) | 2026-07-09 → 2026-09-02 | 55 日 | リポジトリに変更が入った時刻 |
どちらの数字も「存在」します。どちらも嘘ではありません。ただし測っているものが違うので、片方だけを見て「23 日間、記事の代わりに仕組みを作っていた」と言うのは誤りでした。実際に起きたのは「55 日間まったく動かず、再開時に 6 時間で仕組みを作り直し、その 2 日後から記事を書き始めた」です。
この記事の主題は「あると思っていたものが実は動いていない」ですが、その最初の 1 件はこの記事を書くための測定そのものでした。数値を出した後に、別の時計で同じ現象を測り直す。それだけで前提が 1 つ壊れます。
2. 整備の中身を測る:追加行の 79.7% は AI への指示文だった
では 2026-09-02 の 6 時間で何が入ったのか。コミット種別で見ると、リファクタリングと文書が中心です。
git log --after=2026-09-01 --before=2026-09-03 --format='%s' \
| sed -E 's/^([a-z]+)(\(.*\))?!?:.*/\1/' | sort | uniq -c | sort -rn
# 41 refactor / 19 docs / 17 spec / 12 fix / 11 test / 1 feat / 1 security / 2 Merge
より重要なのは、変更行がどこに入ったかです。ファイルを「機械が実行するもの」(scripts/ tests/ .github/ package.json)と「AI に読ませる指示文」(.agents/ .claude/ spec/ .specify/)に分けて、追加行数を集計しました。
| 区分 | ファイル数 | 追加行 | 削除行 | 追加行の比率 |
|---|---|---|---|---|
| AI への指示文 | 36 | 811 | 173 | 79.7% |
| 機械が実行するファイル | 3 | 207 | 8 | 20.3% |
| 合計 | 39 | 1,018 | 181 | 100% |
(社内データ、対象 = 2026-09-02 の 104 コミット)
機械が実行するファイルは 3 つだけです。package.json(2 行)、tests/test_article_role_separation.py(106 行)、tests/test_article_working_ssot.py(99 行)。残り 36 ファイルはすべて、エージェントが読む Markdown でした(内訳は .agents/ 28 / spec/ 5 / .specify/ 2 / .claude/ 1)。
比較のため、記事の執筆を再開した 2026-09-04 以降(26 コミット)を同じ基準で測ると、機械が実行するファイルは 17 個、追加 2,880 行になります。整備日の 207 行に対して約 13.9 倍です(社内データ)。
git log --after=2026-09-03 --numstat --pretty=format: \
| awk -F'\t' '$1!="-" && ($3 ~ /^(scripts|tests|\.github)\// || $3=="package.json") {a+=$1} END{print a}'
# => 2880
指示文が悪いわけではありません。エージェントの振る舞いは指示文で決まるので、これは必要な投資です。問題は指示文は CI で落ちないことです。契約を書いても、その契約が守られているかを機械が確かめる仕組みが同時に入らなければ、増えるのは「ガードがある」という感覚だけになります。
整備が指示文に偏っていたことと、次の節に並ぶ 7 件の欠陥が再開直後に一斉に出たことは、無関係ではないと考えています。ただしこれは 1 リポジトリの 1 回の観測なので、法則として一般化はできません。
3. 効いていないガードの 7 つの型
2026-09-04 の再開から 2026-09-07 までの 4 日間で表面化した欠陥を、壊れ方で分類しました。原因はばらばらですが、症状は全部同じ「CI が緑のまま通る」です。
| 型 | 何が起きるか | 実例 | 一次ソース |
|---|---|---|---|
| D1 死んだ分岐 | 手前の早期 return で、目的の分岐に一度も到達しない | アイコンの deriveInitials が英数トークン 2 つ以上で早期 return し、日本語で区別する分岐が動かず md5 が衝突 | PR #437 |
| D2 常に真の述語 | 検査式そのものが壊れていて、違反を違反と判定しない | ^series:\s*(.*)$ の \s が改行を飲み、ブロック配列を「インライン」と誤判定 | PR #447 |
| D3 発火点が無い | スクリプトは存在するが、どの CI ジョブからも呼ばれない | check:all の 20 スクリプトが ci.yml から到達しない | growth-lab issue #1008 |
| D4 発火条件が守る相手を外す | ジョブは動くが、守るべき PR で起動しない | verify-next-pin が bot 除外ジョブ内にあり、守る相手である dependabot の PR で走らない | growth-lab issue #1008(G1 / G2) |
| D5 静かな no-op | 実行はされ、成功メッセージも出るが、実質何もしていない | update_readmes.py が別マシンの絶対パス固定で、0 件再生成して Done! と exit 0 | PR #435(tests/test_guard_self_check.py の docstring に経緯を記録) |
| D6 充足不能な要求 | 要求が構造的に満たせず、回避しないと CI を通せない | 新規記事に .gitignore 配下のファイルを要求するガードを作ってしまった | PR #448 → #449 → #456 |
| D7 基準が対象と一緒に動く | 判定基準を判定対象と同じループ内で書き換えられる | 改善ループ内で記事の中心主張(Thesis Contract)を差し替えられ、本文を薄めて基準を追随させれば PASS になる | PR #456 のレビュー Finding 2 |
D1 から D7 を一言でまとめると、「検査が動いた」と「違反が無い」を区別する情報が、どこにも出力されていないということです。
以下、性質が違う 4 件だけ補足します。
D1: 4 か月半、誰も踏まなかった分岐
アイコン画像は initials / label / accentColor の 3 つだけで決まり、accentColor は全記事共通です。つまり initials と label が同じなら、別記事でもバイト列まで同一の PNG が生成されます。
deriveInitials() はタイトルの英数トークンから頭文字を作り、joined.length >= 2 で早期 return していました。その下に「日本語 1 文字を足して区別する」分岐がありますが、そこに到達するのは英数トークンが 1 個のときだけです。結果、Claude Code で始まるタイトル群がすべて initials="CC" / label="CLAUDE" に潰れました。
この関数が入ったのは 2026-04-20(PR #152)で、修正は 2026-09-06(PR #437)です。139 日あります。実測された md5 重複は、追跡済み 110 枚に対して 5 グループ。修正前のロジックを最終的な 131 枚に当てはめると 12 グループ / 28 記事、修正後は 0 グループでした(社内データ)。
「AI が生成する画像だから外部 API のコストが気になる」といった話ではなく、生成物のバイト列を照合していなかったという単純な話です。画像の生成有無しか見ていないチェックは、この欠陥を検出できません。
D2: 正規表現の \s が改行を飲んだ
もう 1 件の長期潜伏がこれです。scripts/validate_frontmatter.py にある「series はインライン配列であること」という検査です。
m_series = re.search(r"^series:\s*(.*)$", frontmatter_text, re.MULTILINE)
if m_series and m_series.group(1).strip() == "":
return "FAIL: series must be an inline array (no block list)"
Python の \s は改行を含む空白文字にマッチします(公式ドキュメントに [ \t\n\r\f\v] を含むと明記)。そのため次の入力では、\s* が series: の後の改行と次行のインデントまで飲み、(.*) が - standalone を捕まえます。
series: # ← \s* がここから
- standalone # ← ここまで飲み、(.*) が "- standalone" を捕捉
group(1) が常に非空になるので、この検査は一度も発火しませんでした。導入は 2026-05-16(PR #299)、修正は 2026-09-06(PR #447)で、113 日あります。その間、ブロック形式で書かれた既存記事 11 本が違反のまま通過していました。全数調査の内訳は、PR #447 の直前(8b3dc9f^、記事 213 本)時点でインライン 202 / ブロック 11 / series 無し 0 です(社内データ)。現在は全記事がインラインなので、今日実行しても 11 は出ません。
修正は空白クラスを [ \t] に限定するだけです。1 文字分の差で、113 日の見逃しが生まれていました。
D6: 自分で作った、満たせない要求
これは自分たちで踏んだ穴なので、順を追って書きます。
- PR #448 で「新規記事には Gate-1 の判断記録が必要」というガード(
check_article_working_artifacts.py)を追加した。要求先はdoc/Working/ARTICLE-<slug>/brief.mdとstatus.md。 - しかし
doc/Working/は.gitignore配下(gitignore の仕様どおり、追跡されない)。CI は追跡されていないファイルを見られない。 - 次の記事 PR #449 の作業中に実際に詰まり、いったん
git add -f(git-add の--forceで無視設定を上書き)でコミットして通そうとした。ガードが回避を強制した。 - 最終的に、その #449 のブランチの中でガードのほうを直した。記録の正本を追跡対象の
content/posts/<slug>/gate.mdへ移し、doc/Workingの追加を取り消している。マージされたa6dd404にdoc/Working配下のファイルは 1 件も含まれていません。契約文書側の追随は PR #455 / #456 で行われました。 - 現在このリポジトリで追跡されている
doc/Working配下は 2 件だけです。
git ls-files doc/Working
# doc/Working/.gitkeep
# doc/Working/README.md
D3 から D5 が「ガードが黙る」型なら、D6 は逆に「ガードが騒ぎすぎて、回避が常態化する」型です。このリポジトリでは、ガードを 1 本入れたことで git add -f が記事 PR の手順に紛れ込みました。そこで以後は、「違反を検出できるか」を見る前に「正しい変更が、回避なしで通るか」を先に確かめる順に変えています。
D7: 基準を対象と同じループで動かさない
記事の品質改善ループ(レビュー指摘を反映して再レビューする工程)には、収束条件として「最大 3 回」「前回比 5% 未満の改善で停止」を置いていました。ここには 2 つの穴があります。
1 つ目は、スコアの伸びしか見ていないことです。網羅性を足せば評価スクリプトのスコアは上がるので、中心主張が薄まりながらスコアだけ改善する経路が残ります。
2 つ目が本題で、記事の中心主張(Thesis Contract)をループの中で書き換えられたことです。判定基準そのものが判定対象と一緒に動けるなら、本文を薄めて基準を追随させれば必ず PASS します。PR #456 のレビューはこれをブロッカーとして指摘し、対応としてループ中の Contract 変更を「強める方向も含めて全面禁止」にしました。
同じ PR では、契約文書が隣の行で自己矛盾していたことも指摘されています。「doc/Working は追跡されないので判断記録の正本にしてはならない」と書いた直後に、正本を doc/Working に指定していました。D6 と同じ穴を、それを禁止する文書自身が踏んでいたわけです。
番外:レビューを 3 回通しても残る
7 つの型とは別に、記録しておくべき事実があります。PR #454 の記事は、CI green・Codex による独立レビュー・3 者視点レビュー 2 回を通過していました。そのうえで、指摘ごとに再現実験を要求する別系統のレビュー(このリポジトリでは River Review と呼んでいます)が Major 6 件を検出しています(社内データ。内訳は content/posts/ai-bom-code-provenance/gate.md の Finding 表 F1-F10 に全件記録されており、F1-F6 が Major、F7-F10 が Minor)。
そのうち 1 件(F1)は、前の指摘で修正したはずのバグが、別経路で同じエラー文言を出すものでした。git ls-tree が submodule(gitlink)も列挙するため、git blame が修正前とまったく同じ fatal: no such path ... in <BASE> で停止します。読者は「直っているはずの箇所」を疑わないので、原因に到達できません。
なお同じ PR では、この F1 を直した実装に対して 2 巡目のレビューがさらにブロッカーを 1 件出しています。今度は git ls-tree --format が -z を付けてもパスを C-quote するという別の理由で、やはり同じ fatal: no such path が出ます。同じ症状に別の原因が 2 回続いた、という記録として残っています。
レビューの回数はガードの強度ではありません。同じ観点のレビューを 3 回通しても、その観点の外は 3 回とも素通りします。
4. 発火を確かめる:壊した入力を 1 回通す
ここからが読者に持ち帰ってほしい部分です。手順は 1 つだけです。
正常入力で
exit 0になることに加えて、わざと壊した入力で非 0 になり、エラーが違反箇所を名指しすることを確認する。
これを「二面検証」と呼んでいます。考え方自体は新しくありません。ミューテーションテストは、コードに人工的な欠陥を埋め込んでテストが落ちるかを測る手法で、PIT は「変異が kill されたか survive したか」を出力します。Google はこれを大規模に運用しており、Practical Mutation Testing at Scale では 1,000 以上のプロジェクト・24,000 人以上の開発者に適用した結果が報告されています。カバレッジ率を目標値にすることの危うさは Martin Fowler の TestCoverage が「高い数値は低品質なテストでも容易に届く」と書いているとおりで、ここでも測るべきは割合ではなく、検出できるかどうかです。
違うのは対象です。プロダクトコードではなく、ガードそのものに変異を当てます。AI が生成したテストに同じ考え方を当てる話は AI生成テストの信頼性を上げる にまとめてあります。
このとき、合否の判定に使うのは終了コードだけです(メッセージは、違反箇所を名指ししているかを確かめるために読みます)。両者は独立に壊れます。同じリポジトリの昇格判定ガードは、標準出力に「昇格が必要」と正しく表示しながら exit 0 を返していました(AIの推論を自動化へ昇格させるループの穴)。人間が読む行だけを見て確かめると、この形は合格に見えます。
次のスクリプトは、D2 の欠陥をそのまま再現します。このリポジトリのルートで実行してください。前提は bash / python3 / perl / git(origin を fetch 済み)で、作業ツリーは一切書き換えません。
#!/bin/bash
# 二面検証デモ: series インライン強制が「発火するか」を確かめる
set -u
REPO="$(git rev-parse --show-toplevel)"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/content/posts/demo"
cp "$REPO/content/posts/ai-pr-stacked-decomposition/index.md" "$TMP/content/posts/demo/index.md"
echo "--- (1) 正常入力 ---"
python3 "$REPO/scripts/validate_frontmatter.py" "$TMP/content/posts" >/dev/null 2>&1
echo "exit=$?"
echo "--- (2) series をブロック配列へ壊す ---"
perl -0pi -e 's/^series: \["standalone"\]$/series:\n - standalone/m' "$TMP/content/posts/demo/index.md"
python3 "$REPO/scripts/validate_frontmatter.py" "$TMP/content/posts" 2>&1 | grep -i fail | head -1
python3 "$REPO/scripts/validate_frontmatter.py" "$TMP/content/posts" >/dev/null 2>&1
echo "exit=$?"
echo "--- (3) 修正前のガード(\\s*)で同じ入力を検査 ---"
sed 's|\^series:\[ \\t\]\*(\.\*)\$|^series:\\s*(.*)$|' \
"$REPO/scripts/validate_frontmatter.py" > "$TMP/old_guard.py"
python3 "$TMP/old_guard.py" "$TMP/content/posts" >/dev/null 2>&1
echo "exit=$?"
実行結果です(2026-09-07、社内データ)。
--- (1) 正常入力 ---
exit=0
--- (2) series をブロック配列へ壊す ---
.../content/posts/demo/index.md: FAIL: series must be an inline array (no block list)
exit=1
--- (3) 修正前のガード(\s*)で同じ入力を検査 ---
exit=0
(1) だけを見ると、修正前も修正後も緑です。(2) と (3) を並べて初めて、\s を [ \t] に変えたことに意味があったと言えます。逆に言えば、(3) を試さない限り「このガードは以前から効いていた」と信じ続けられます。
このリポジトリでは、この形式のテストを tests/test_guard_self_check.py に置いています。実リポジトリは一切書き換えず、壊した入力はすべて tempfile.TemporaryDirectory() 内のコピーに作ります。ガードのテストが本物のファイルを壊すと、それ自体が事故になるためです。
CI ジョブの配線漏れ(D3 / D4)は、この形式では捕まりません。そちらは**「守る対象の PR で実際に発火するか」**を別に確認する必要があります。GitHub Actions は paths フィルタで対象ファイルの変更が無ければ workflow を起動しません。スクリプトが正しくても、起動条件が守る相手を除外していれば結果は D5 と同じです。
5. その二面検証も摩耗する
ここで話を終えると、「二面検証テストを入れれば解決」という誤解を残します。実際は違いました。
scripts/validate_frontmatter.py の validate_frontmatter() を読むと、検出分岐は 9 個あります(末尾の汎用例外ハンドラは除く)。
一方、壊した入力でこれを検証しているテストは 2 ファイル(tests/test_guard_self_check.py と tests/test_validate_frontmatter.py)にあり、両方を合わせても検証されている分岐は 4 個です。
| 検出分岐 | 壊した入力のテスト |
|---|---|
| frontmatter が無い | あり |
| トップレベルキーの重複 | あり |
| 必須フィールド欠落 | あり |
series キー欠落 | あり |
series が配列でない | なし |
series が空配列 | なし |
series がブロック配列(D2 で直した分岐) | なし |
series_id があるのに series_role 欠落 | なし |
series_id があるのに series_order 欠落 | なし |
(社内データ、2026-09-07 時点)
つまり D2 で修正した分岐には、修正後もテストがありません。しかもテストファイルには、修正前に書かれたコメントがそのまま残っています。
# NOTE: 「series をブロック配列にする」壊し方は現状のガードでは検出できない。
# ...「複数行 YAML 配列を禁止」の判定が一度も発火しない(dead check)。
# 修正には content/posts 配下 11 記事の frontmatter 変更が必要なため、
# 本 PR のスコープ外とし別途対応する。
このコメントは現在は事実ではありません(前節の実行結果のとおり exit=1 で検出されます)。ガードは直り、コメントとテストの穴だけが残りました。
ただし公平に書くと、D2 を最初に見つけたのはこのコメントを書いた作業です。2026-09-05 に「壊した入力のテストを書こう」として、書けないことに気づいた。つまり二面検証は、テストとして残らなくても書こうとした時点で欠陥を検出しています。効いたのはテストではなく、手順のほうでした。
ガードスクリプトの単位でも同じ状況です。scripts/ 配下の検査系スクリプトは 10 本あり、tests/ から名前を参照されているのは 8 本、audit_article_pipeline.py と validate_code_references.py は 0 件です(社内データ)。
ls scripts | grep -E '^(check|validate|audit|verify)' | wc -l # => 10
# 各スクリプト名で tests/ を grep し、参照が無いものを洗い出す
for f in $(ls scripts | grep -E '^(check|validate|audit|verify)' | sed 's/\.[^.]*$//'); do
grep -rql "$f" tests/ >/dev/null 2>&1 || echo "no test: $f"
done
# => no test: audit_article_pipeline
# no test: validate_code_references
SRE ワークフローの議論でも、本番と同じ条件で試せない復旧ツールは「いざというときに動くという確信が持てない」と整理されています。Chaos Engineering の原則が本番で実験することを求めるのも同じ理屈です。壊してみない限り、耐えられるかは分かりません。
追記(2026-09-07・公開後): 本節で挙げた穴は塞ぎました
上の記述は公開時点(2026-09-07)の観測としてそのまま残します。 本記事の公開後、同日中に別 PR(PR #461)で次の 3 件を修正しました。この記事自身が書いている「測定は日付とセットでしか意味を持たない」の実践です。
| 本節の記述 | 公開後の対応(PR #461) |
|---|---|
tests/test_guard_self_check.py の NOTE が事実でない | NOTE を削除し、「PR #447 で分岐が生きたので二面検証する」というコメントに差し替え |
| ブロック配列の壊した入力ケースが 0 件 | ブロック配列 2 ケース + インライン配列が通ること 1 ケースを追加(過検出も検査) |
audit_article_pipeline.py / validate_code_references.py が tests/ から参照 0 件 | 両方に二面検証を追加(計 11 ケース)。ガード自己検証テストは 31 → 49 ケース |
修正の過程で、同じ型(D2)の未検出インスタンスがもう 3 つ見つかりました。validate_frontmatter.py と audit_markdown_rules.py は、ディレクトリを渡したときは終了コードで判定するのに、単一ファイルを渡したときは結果を表示するだけで常に exit 0 でした。そして audit_article_pipeline.py はこの 2 本を単一ファイルで呼び、さらに標準出力に error / fail の文字列が含まれるかで合否を決めていたため、記事本文にその語が出るだけで判定が変わる状態でした。結果として、このパイプラインは違反を見つけても一度も非 0 で終われませんでした。いずれも同 PR で終了コード判定に直しています。
つまり、「テストが 0 件のガード」を調べに行くと、そのガード自体が壊れている確率は低くありません。 参照 0 件は「テストを書き忘れた」ではなく「一度も動かして確かめていない」の指標として読むほうが正確でした。
6. このリポジトリで使っている 3 つの問い
上の 7 つの型を、実際の棚卸しで使える形に畳んだものです。このリポジトリでは、新しいガードを足すときと、既存のガードを見直すときの両方でこの 3 問を通しています。
問い 1: このガードは、どの PR で起動するか。
起動する workflow / ジョブ名を具体的に言えなければ D3 です。言えたとしても、それが paths フィルタや bot 除外の内側にないかを確認します。守る相手(dependabot の PR、記事だけを変更する PR、生成物だけが変わる PR)を 1 つ挙げて、その PR で本当に走るかを見ます。これが D4 の検査です。
問い 2: このガードを、どう壊せば落ちるか。 壊し方を 1 つも書けないなら、そのガードは検証されていません。前節のとおり、書こうとした瞬間に欠陥が出ることがあります。壊す入力は、テストで使う形ではなく本番が渡してくる形にします。同じリポジトリの PreToolUse フックは、テストで使っていた payload では止まるのに本番の形状では 1 件も止めていませんでした(AIエージェントの権限は影響範囲で設計する)。壊す対象は必ず一時ディレクトリのコピーにします。落ちたときのメッセージが違反箇所を名指ししているかまで見ます(名指ししないガードは、導入初日に全件赤になって無効化されます)。
問い 3: このガードは、正しい変更を回避なしで通すか。
git add -f や skip-* ラベルを前提にしないと通らないなら D6 です。要求先が .gitignore 配下でないか、生成物のように差分が出ないものを要求していないかを確認します。判定基準を持つガードなら、その基準を、判定される側が同じループの中で変更できないかも見ます。できるなら D7 です。
順序も重要です。既存のガードを棚卸しするなら、問い 1 → 問い 2 → 問い 3 の順が効率的でした。問い 1 は grep だけで済み、ここで落ちるものが最も多かったためです(このリポジトリと growth-lab の 2 リポジトリでの観測です)。
ガード自体をどう設計するかは LLMガードレール設計、記事の品質ゲートを含む運用側の設計は AI記事の品質管理を仕組みにする と CIで止めるAIテスト品質ゲート に分けて書いています。生成物のバイト列を照合する話(D1 の再発防止)は AI-BOMでコードの出所を可視化する と地続きです。
7. 限界と、やらないほうがいい場合
正直に書いておきます。
このリポジトリでも、すべてのガードに二面検証を書いてはいません。 上の棚卸しのとおり audit_article_pipeline.py と validate_code_references.py は現時点で 0 件のまま残しています。優先順位は「過去にそれで事故が起きた」ものと「壊れても静かなもの」を先に、壊れたときに派手に落ちるガード(例: 構文エラーでプロセスが死ぬ類)を後に、という置き方にしています。
このリポジトリでは、二面検証のカバレッジ率を目標値として扱っていません。 「検出分岐の 100% に壊した入力のテストを」と決めれば、通すためだけの薄いテストが増える方向に働きます。9 個中 4 個という数字を出したのは達成率としてではなく、どの分岐が未検証かを名指しするためです。
ガードの本数も、同じ理由で目標値にしていません。 本数は増える一方で減らない数字なので、「何を検査していないか」を教えてくれません。登録済みガードを 1 本ずつ異常系まで読んで確かめた結果と、それでもすり抜けた失敗がどこにあったのかは tokenmaxxingを避けるAI開発KPI に記録しています。
この記事の数字は 1 リポジトリ・4 日間の観測です。 7 つの型は、他のリポジトリでも同じ比率で出るという主張ではありません。分類が使えるかどうかは、読者が自分のリポジトリで問い 1 を 1 回回してみれば分かります。
「効いていないなら消せばいい」は、この記事では扱いません。 発火していないガードを見つけることと、それを削ってよいと判断することは別の作業です。同じリポジトリの全履歴で「賢くなれば Harness は削れるのか」を測り、削除の根拠を参照エッジと二面検証の有無に置いた続きは AI Harnessの技術的負債と削る基準 にあります。
そして、D2 と同じ穴がこの記事の中にもあり得ます。 §5 で書いたとおり、二面検証のテスト自体が摩耗し、コメントが事実でなくなる状態を、このリポジトリは今この瞬間も抱えています。仕組みは入れた瞬間から古くなるので、「入れたから大丈夫」は成立しません。
まとめ
- CI の緑には「違反が無い」と「検査が動いていない」の 2 通りがあり、画面上は区別できません。
- このリポジトリでは、記事の執筆を再開した 4 日間で、効いていないガードが 7 種類出ました。うち 2 件は 139 日 / 113 日のあいだ潜伏していました。
- 整備に費やした時間の中身を測ると、追加行の 79.7% は AI に読ませる指示文で、機械が実行するファイルは 3 つでした。指示文は CI で落ちません。
- 確かめ方は「壊した入力を 1 回通す」だけです。テストとして残らなくても、書こうとした時点で欠陥が出ます。
- その二面検証も摩耗します。検出分岐 9 個のうちテストがあるのは 4 個で、直した分岐には今も 0 件です。
まず手元のガードを 1 本選んで、「どの PR で起動するか」を答えてみてください。答えられなければ、そのガードはまだ何も守っていません。
FAQ
Q1. ガードが「効いていない」かどうかを、一番安く見つける方法は何ですか
起動する workflow / ジョブ名を言えるかを確認することです。grep だけで済み、このリポジトリと growth-lab の観測では、ここで落ちるものが最も多くありました。言えたら次に、壊した入力を 1 回通して非 0 で落ちるかを見ます。
Q2. 二面検証は、通常のユニットテストと何が違いますか
対象が違います。通常のユニットテストはプロダクトコードの振る舞いを検証しますが、二面検証はガードそのものに変異を当てて、違反を検出できることを確認します。正常入力で exit 0 になることだけを確認しているテストは、検査が 1 行も走っていない状態も PASS にします。
Q3. \s が改行にマッチする問題は、Python 以外でも起きますか
正規表現の空白クラスに改行を含むかは処理系によりますが、Python の \s は 公式ドキュメント に [ \t\n\r\f\v] を含むと明記されています。行末までを捕まえたい意図で ^key:\s*(.*)$ と書くと、re.MULTILINE でも \s* が改行をまたぐため、次行の内容を拾います。空白クラスを [ \t] に限定するのが確実です。
Q4. ガードを追加したのに CI を通せなくなった場合、どうしましたか
このリポジトリでは、git add -f や skip ラベルでの回避を続ける前に、ガードの要求そのものを疑う順に切り替えました。.gitignore 配下のファイルや、差分の出ない生成物を要求するガードは構造的に満たせません。最終的に判断記録の正本を .gitignore 配下から追跡対象の content/posts/<slug>/gate.md へ移すことで解消しました(PR #449 の中でガード側を修正)。
Q5. レビューを複数回通せば、この種の欠陥は防げますか
防げません。CI green・Codex レビュー・3 者視点レビュー 2 回を通過した記事から、指摘ごとに再現実験を要求する別系統のレビューが Major 6 件を検出した実例があります(PR #454)。うち 1 件は、前の指摘で修正したはずのバグが別経路で同じエラー文言を出すものでした。回数ではなく、観点が違うレビューを 1 回入れるほうが効きました。
References
- Python 3 —
remodule(\sが[ \t\n\r\f\v]を含むこと、re.MULTILINEの$の挙動) - PIT Mutation Testing(変異を埋め込みテストが kill できるかを測る)
- Practical Mutation Testing at Scale: A view from Google(arXiv:2102.11378)(1,000 以上のプロジェクト・24,000 人以上への適用)
- Martin Fowler — TestCoverage(カバレッジ率の目標値化への警告)
- Google SRE Book — Testing for Reliability(試せない復旧ツールは動く確信が持てない)
- Principles of Chaos Engineering(実験によって弱点を発見する)
- GitHub Actions — Workflow syntax(
pathsフィルタで workflow が起動しない仕様) - git — gitignore(追跡されないファイルの扱い)
- git — git-add
--force(無視されたファイルの追加)
