TL;DR
- 基準時点: 2026-09-10 /
origin/main=a184564(記事 224 本)。 以下の数値はすべてこの時点の本ブログのリポジトリでの実測です(社内データ)。 - 「ガードが存在するのに発火しない」欠陥を塞ぐ PR が、その PR の中で同じ型の欠陥を作り込んだ実例が、2026-09-09〜10 の 2 日間に 5 件ありました。すべて PR 番号・コミット・失敗台帳の行に紐づけてあります(本リポジトリは private なので、読者が直接開いて確かめることはできません。再現できる形にしたのは 3 節です)。
- 5 件に共通するのは「気の緩み」ではなく座標の移動です。無力化できる場所が、実装 → 配線 → CI の
run:→run:の外側の YAML → 判定関数の内部、と 1 段ずつ移ります。 外向きが 3 回、内向きが 2 回、検証入力の側へ 1 回で、一方向ではありません。固定した座標の隣が、次の無力化の座標になります。 - 最後の 1 件(title 重複ガードの「免除リストを持てない」保証)はいまも残っています。判定関数の入口で実在 slug を 1 行除外すると、実際に title が完全一致する記事が 2 本ある状態でガードが
exit 0、そのうえ自己検証テスト 191 件がすべて緑でした(使い捨てコピーで実測)。 - 期間内に merge されたガード系 PR は 6 本で、うち 3 本にこの型の欠陥がありました(計 5 件。#480 だけで 3 件)。n=5・同一リポジトリ・同一週の観察であり、発生率として一般化できる数字ではありません。
- 持ち帰れる操作は 1 つです。ガードを直したら、そのガードを「起動しない形」に 1 行書き換えて、テストが赤くなるかを見る。 赤くならなければ、直したのは検出ロジックだけで、検証は何も守っていません。
はじめに:「直した」と「守られている」は別
このブログは記事の生成と検査を AI エージェントに任せて運用しています。検査は scripts/ 配下の Python / Node スクリプトで、pnpm test と GitHub Actions の両方から起動する建て付けです。
2026-09-07 に公開した 効いていないガードを見つける7つの型 で、「存在するのに一度も発火していないガード」を 7 種類に分類しました。あの記事はある時点のスナップショットです。棚卸しをして、型に名前を付けて、壊した入力を 1 回通す(二面検証)という確かめ方を書きました。
そのあと、分類した穴を実際に塞ぐ作業に入りました。本記事はその作業のほうの記録です。結論から書くと、塞ぐ側の PR が、塞ごうとしている欠陥とまったく同じ型の欠陥を、自分の中に作っていました。
これは反省文ではありません。5 件を並べると、偶然や不注意では説明のつかない規則性が見えます。それを書きます。
1. 基準時点と、何を分母にしたか
基準は origin/main = a184564(2026-09-10 09:41 JST、記事 224 本)です。以降のコミットで値は動きます。
先に断っておきます。本リポジトリは private です。 以下に出す PR 番号・コミット SHA・失敗台帳の行 ID は「その主張の根拠がどこにあるか」を示すためのもので、読者が開いて確認することはできません。3 節の再現手順も、そのままでは動きません(あなたのリポジトリの、あなたのガードに読み替えてください)。読者が自分の環境で検証できるように書いたのは 5 節です。
期間は 2026-09-09 00:00 〜 2026-09-10 09:41(JST)。この期間に merge された commit は 18 本で、そのうちガード(CI 検査・フック・検査スクリプト)を追加または修正した PR は 6 本でした。
# 期間内の merge commit を列挙する(基準ツリーを SHA で固定する。origin/main は動く)
git log a184564 --since=2026-09-09T00:00:00+09:00 \
--date=format:'%Y-%m-%d' --pretty='%h %ad %s'
# => 18 行。うちガード(CI 検査・フック・検査スクリプト)を足した/直した PR は 6 本
# #473, #476, #480, #482, #485, #491
# subject は feat(guard) / fix(guard) のほか、#476 は素の refactor:(配線の PR)
この 6 本のうち 3 本(#476 / #480 / #491) に、「その PR が塞ごうとしていた欠陥と同じ型の欠陥」が PR 自身の中にありました。件数としては 5 件です(#480 だけで 3 件。失敗台帳の L-0020 / L-0021 / L-0022 がいずれも「#480 の作業中に作り込み、同 PR 内で検出・修正した観測」として記録されています)。
残り 3 本のうち #482 も欠陥を作り込んでいますが、型が違います。台帳 L-0033 に記録したとおり、check_ledger_id_collision.py の比較が既存行をバイト一致で見ていたため、SSoT が要求する状態遷移(open → fixed)がそのまま違反になりました。「発火しない」ではなく「充足不能」で、これは別の型です。#473 と #485 にはこの型の欠陥を見つけていません。
分母の扱いについて一言。「6 本中 3 本」を発生率として読まないでください。 私は「対策 PR に同じ欠陥があるか」という問いを持って 6 本を読み、その問いに合う 5 件を抜き出しています。探せば見つかる方向のバイアスがかかっていますし、n=5・同一リポジトリ・同一週・同一の書き手(AI エージェント + 私)です。ここで主張できるのは「起きた」と「起き方に規則性がある」までで、「よく起きる」ではありません。
2. 5 件を一次資料で並べる
2-1. 到達性メタテストが部分文字列一致だった(PR #476 / 台帳 L-0015)
塞ごうとしていた穴: scripts/validate_code_references.py にはテストがあるのに、pnpm test からも CI からも起動されておらず、実データに一度も当たっていなかった。
やったこと: pnpm test:code-refs として package.json の連鎖に配線し、「配線が到達しているか」を検証するメタテスト GuardReachabilityTest を追加しました。
作り込んだ穴: その到達性判定が部分文字列一致でした。
# tests/test_guard_self_check.py(PR #476 の 1 コミット目)
def _reaches(scripts: dict, file_name: str) -> bool:
"""`pnpm test` の連鎖のどこかで file_name が起動されるか。"""
return any(file_name in scripts[name] for name in _pnpm_test_chain(scripts))
file_name は "validate_code_references.py" です。npm script の値にこの文字列が含まれてさえいれば True を返します。つまり次の 4 通りの書き換えは、すべてガードを起動させないか、失敗を伝播させないのに、メタテストは緑のままでした。
| 書き換え | 値 | 実際に起きること |
|---|---|---|
echo に差し替え | echo skip python3 scripts/validate_code_references.py | 起動しない |
| 失敗を握り潰す | python3 scripts/validate_code_references.py || true | 起動するが exit code が常に 0 |
| コメントアウト | # python3 scripts/validate_code_references.py | 起動しない |
| 引数で対象を縮める | python3 scripts/validate_code_references.py content/posts/<slug>/index.md | 221 本中 1 本しか検査しない(2fc7427 時点の記事数。基準時点の 224 本とは別) |
直し方: 値の完全一致に変えました(コミット 2fc7427)。
CODE_REFS_COMMAND = "python3 scripts/validate_code_references.py"
def _reaches(scripts: dict, command: str) -> bool:
return any(scripts[name].strip() == command for name in _pnpm_test_chain(scripts))
加えて、pnpm test:code-refs を実際に subprocess で走らせ、ガードを直叩きした結果と exit code / stdout を突き合わせるテストと、引数なしの既定対象が content/posts 全件であることを固定するテストを足しました。
ここに最初の非対称があります。「このガードは何を検出すべきか」には検証が付いていました(ValidateCodeReferencesGuardTest)。「このガードは起動するか」の検証にだけ、検証が付いていませんでした。
2-2. 新規 17 テストが pnpm test からも CI からも起動していなかった(PR #480)
塞ごうとしていた穴: 記事制作の失敗を台帳へ起票し忘れても、既存の 3 検査はいずれも台帳を入力にしているため、起票しなければ件数は増えず閾値にも達しない。検出は構造的に不可能だった(既知の穴 H1)。
やったこと: 入力を反転させ、PR の差分を入力にする scripts/check_ledger_entry_gate.py を作り、tests/test_ledger_entry_gate.py に 17 個のテストを書きました。
作り込んだ穴: その 17 テストが、pnpm test からも CI からも一度も起動していませんでした。
経緯は commit に残っています。無力化の実験(配線を外して本当にテストが落ちるかを見る手順)のあと git checkout package.json で巻き戻した際、まだ commit していなかった test:guards への追加が一緒に消えていました。
# PR #480 の 1 コミット目(6748c88)
git show 6748c88:package.json | grep '"test:guards"'
# => "test:guards": "python3 -m unittest tests.test_guard_self_check",
# 修正後(d7b02c8)
git show d7b02c8:package.json | grep '"test:guards"'
# => "test:guards": "python3 -m unittest tests.test_guard_self_check tests.test_ledger_entry_gate",
git show 6748c88:tests/test_ledger_entry_gate.py | grep -c " def test_"
# => 17
注意: 6748c88 と 2-1 の 2fc7427 は squash merge された PR の途中コミットなので main からは辿れません。リモートに元ブランチ(origin/improve/harness-ledger-entry-gate / origin/refactor/wire-unreached-guards)が残っているあいだだけ解決します。d7b02c8 は main 上のコミットです。
この間、pnpm test は exit 0 でした。当然です。存在しないモジュールは失敗しません。pnpm test の exit 0 は「そのテストが走った」を意味しません。「走った検査がすべて通った」しか意味しません。 走っていない検査は、この差から見えません。
検出したのは、同じ PR で自分が書いた PnpmWiringTest.test_guards_script_is_reachable_and_exact でした。2-1 で spec/guard_self_check.md の R-2(対象ガードの網羅)の表を、実際の起動経路に合わせて更新した直後だったので(R-2 自体はもっと前の PR #435 で入っています)、その表に従って書いたテストが自分の配線漏れを捕まえた形です。テストの期待値ではなく実装(配線)を直しました。
ただしこの PnpmWiringTest 自体が、配線されていない tests/test_ledger_entry_gate.py の中にあります(git show 6748c88:tests/test_ledger_entry_gate.py | grep -n "class PnpmWiringTest" で確認できます。現在のツリーで同じ grep をかけると 3 ファイルに当たりますが、それは後続 PR が同型のテストを他モジュールへ広げたためです)。つまり pnpm test からは起動しません。落ちたのは、手元でモジュールを直接叩いたときです。自分の未配線を自分で検出するテストは、配線されるまで沈黙します。 これも同じ階段の一段で、「規約に従って書いたから自動的に捕まった」という読み方はできません。
2-3. 検出分岐が合成 fixture の語順にしか当たらなかった(PR #480 / 台帳 L-0022)
同じ PR の 2 件目です。
やったこと: gate.md に「確定レビュー指摘」が記録されているかを検出する分岐を 3 通り書き、それぞれにテストを付けました。
作り込んだ穴: その 3 分岐が、テストで使った合成 fixture の語順にしか一致しませんでした。実データ(ai-pr-stacked-decomposition/gate.md の Critical 1・Major 4 を確定指摘として反映済み や severity 表記の Major)に対しては 3 分岐とも外れ、一度も発火しませんでした。
テストは緑です。当然です。テストが渡す入力と、検出が想定している語順が、同じ人間の頭から同時に出ているからです。fixture を書いた人と検出を書いた人が同一なら、両者は必ず噛み合います。 噛み合わないのは実データとの間だけで、実データはテストに入っていません。
無力化の座標が、また動いています。ここまでは「起動するか」の話でしたが、今回は起動しています。起動したうえで、入力が当たらない。座標は外側ではなく、判定の入口——何を入力とみなすか——の側へ動きました。
直し方: 実データの gate.md を fixture に加えました。二面検証の「違反入力」を合成で作るなら、同じ検査を実リポジトリの実データにも 1 回通す必要があります。
この型は 3 節でもう一度出てきます。title 重複ガードの「免除リストを持てない」ことを保証するテスト test_no_frontmatter_field_exempts_a_duplicate も、一時ディレクトリの合成 fixture(slug は dup)で検査していて、実在 slug の除外には当たりません。同じ型が 2 回出ました。
なお #480 は、レビュー対応の中でもう 1 件直しています。新設したガードが「トリガ 0 件」を「起票義務なし」と同じ終了文言で報告しており、これは本記事が 5 節 (2) で推奨している規約(spec/guard_self_check.md R-6 規約 5)の自己違反でした。ただしこれは「発火しない」ではなく「0 件を合格と同じ顔で報告する」型なので、5 件には数えていません。
2-4. メタテストが workflow の run: しか読まなかった(PR #480 / 台帳 L-0021)
同じ PR の中の 3 件目です。
やったこと: 2-1 の教訓を受けて、メタテストを部分文字列一致で書かない方針にしました。workflow から run: の文字列を取り出し、その文字列をそのまま bash で実行して、違反 fixture で非 0 になることを終了コードで確かめる形にしました。ここまでは正しい方向です。
作り込んだ穴: 見ているのが run: の中だけでした。GitHub Actions では、run: に一切触れずにジョブを無効化する方法が少なくとも 3 つあります。
| 座標 | 書き換え | 結果 |
|---|---|---|
on.pull_request.paths | ガードのパスを外す | PR で workflow 自体が起動しない |
job の if: | if: ${{ false }} | ジョブ全体が skip |
step の continue-on-error | continue-on-error: true | 失敗しても job は成功 |
いずれも run: の文字列は 1 文字も変わりません。無力化の座標が、run: の中から run: の外側の YAML へ 1 段ずれただけで、メタテストは再び黙りました。今度は外向きで、2-1 とまったく同じ型です。
直し方: run: の実行に加えて、yaml.safe_load で workflow を構造として読み、外側を固定しました。
class CiStructureTest(unittest.TestCase):
def test_workflow_has_no_path_filter(self):
wf = self._workflow()
# `on` は YAML 1.1 で真偽値に解釈されるため、両方のキーを見る
triggers = wf.get("on", wf.get(True))
pull_request = triggers["pull_request"] or {}
self.assertNotIn("paths", pull_request)
self.assertNotIn("paths-ignore", pull_request)
def test_job_condition_is_exactly_the_allowed_one(self):
self.assertEqual(_norm_expr(self._job().get("if", "")), CI_JOB_IF)
def test_step_has_no_conditional_or_soft_failure(self):
step = self._step()
self.assertNotIn("continue-on-error", step)
self.assertNotIn("if", step)
on を wf.get("on", wf.get(True)) で読んでいるのは、YAML 1.1 で on が真偽値 True に解釈されるためです。ここを wf["on"] と書くと KeyError になります。
2-5. 「免除の器を作れない」保証に抜け道が残った(PR #491)
塞ごうとしていた穴: title の一意性を検査するガードが 1 本も無く、agent-engineering-shift と from-prompt-to-agent-engineering は title も date も完全一致のまま約 7 か月公開が続いていた(台帳 L-0034)。
やったこと: scripts/check_title_uniqueness.py を作り、pnpm test:titles として配線しました。設計上の目玉は「免除リスト(baseline)を一切持たない」ことです。下流リポジトリの ASSET_CHECK_SKIP_SLUGS が約 4 か月 stale のまま放置された事例(台帳 L-0035)を踏まえ、免除の器がなければ免除が stale 化する経路も生まれない、という判断でした。
問題は、その「器が無い」をどう保証するかです。最初の実装は、guard 本体のソースを識別子名で走査するものでした。
# 補助テストとして現在も残っている形
pattern = re.compile(
r"waiver|skip_?slug|exempt|allow_?list|ignore_?slug|baseline|grandfather",
re.IGNORECASE,
)
これは 7 語幹の識別子名を大文字小文字を無視して拾う部分文字列一致です。小文字での定義・frontmatter の値による条件除外・外部ファイルからの読み込みで素通りできることが独立レビューで指摘され、振る舞い検査へ直しました。「実在しうる除外条件(status: archived / draft / noindex / canonical / redirect_from / series)を全部載せた記事が、それでも重複として落ちること」を要求する形です。
方向は正しい修正です。それでも抜け道が残りました。
3. 未対応の 1 件(2-5)を再現する
残っている抜け道は「判定関数の入口で、実在 slug を 1 行除外する」形です。使い捨てのコピーで再現しました(リポジトリ内のファイルは 1 つも変更していません)。
前提: Python 3.12 / PyYAML 導入済み / git が使えること。
手順 1: 使い捨てコピーを作る
mkdir -p /tmp/titlerepro/repo
git archive a184564 | tar -x -C /tmp/titlerepro/repo # 基準時点のツリーを取り出す
cd /tmp/titlerepro/repo
python3 -m unittest tests.test_title_uniqueness 2>&1 | tail -3
# => Ran 30 tests in 3.609s
# OK
ベースラインは 30 テスト緑です。以降の出力に出てくる 224 件 はこの基準時点の記事本数で、あとから記事が増えれば当然変わります。変わらないことに意味があるのは 3 段目の「除外を入れても件数が動かない」ほうです。
手順 2: 本物の title 重複を仕込む
既存記事 harness-engineering-debt の title を、別の既存記事 guard-not-firing-patterns の title と完全一致させます。
perl -pi -e 's/^title: "AI Harnessの技術的負債と削る基準"/title: "効いていないガードを見つける7つの型"/ if $. < 20' \
content/posts/harness-engineering-debt/index.md
python3 scripts/check_title_uniqueness.py --require-target; echo "exit=$?"
検査対象: 224 件の index.md
完全一致の重複: 1 組
正規化後一致の重複: 0 組
ERROR: title が完全一致で重複しています: '効いていないガードを見つける7つの型'
- content/posts/guard-not-firing-patterns/index.md(title: '効いていないガードを見つける7つの型')
- content/posts/harness-engineering-debt/index.md(title: '効いていないガードを見つける7つの型')
対処: ...(改題手順と「重複を許す免除リストはありません」の 3 行。ここでは省略)
exit=1
ガードは正しく落ちます。ここまでは健全です。
手順 3: 判定関数の入口に 1 行足す
perl -0pi -e 's/(\) -> list\[tuple\[str, list\[tuple\[Path, str\]\]\]\]:\n)( groups)/$1 articles = [r for r in articles if "guard-not-firing-patterns" not in str(r[0])]\n$2/' \
scripts/check_title_uniqueness.py
結果はこうなります。
def find_duplicates(
articles: list[tuple[Path, str]], keyfn
) -> list[tuple[str, list[tuple[Path, str]]]]:
articles = [r for r in articles if "guard-not-firing-patterns" not in str(r[0])]
groups: dict[str, list[tuple[Path, str]]] = defaultdict(list)
for path, title in articles:
groups[keyfn(title)].append((path, title))
return [(key, rows) for key, rows in groups.items() if len(rows) > 1]
これは実質的に「1 slug の恒久免除」です。免除リストらしい識別子名は 1 つも出てきません。
手順 4: 実測
python3 scripts/check_title_uniqueness.py --require-target; echo "guard exit=$?"
検査対象: 224 件の index.md
完全一致の重複: 0 組
正規化後一致の重複: 0 組
title uniqueness check passed.
guard exit=0
title が完全一致する記事が 2 本ある状態で、ガードは「重複 0 組」と報告して合格します。 そのうえで自己検証テストを回します。
python3 -m unittest tests.test_title_uniqueness 2>&1 | tail -3
# => Ran 30 tests in 3.509s
# OK
# pnpm test:guards が起動する 5 モジュール全部
python3 -m unittest tests.test_guard_self_check tests.test_ledger_entry_gate \
tests.test_ledger_id_collision tests.test_readme_sync tests.test_title_uniqueness 2>&1 | tail -3
# => Ran 191 tests in 63.245s
# OK
191 テスト全緑です。以下が全部通ります。
| テスト | なぜ通るか |
|---|---|
test_guard_has_no_exemption_list | 正規表現が識別子名 7 語幹しか見ていない。slug 文字列はどれにも一致しない |
test_no_frontmatter_field_exempts_a_duplicate | 一時ディレクトリの合成 fixture(slug は dup)で検査する。実在 slug の除外は当たらない |
SatisfiabilityTest.test_wired_form_covers_the_whole_corpus(走査件数の突合) | 期待値は content/posts の index.md を数え直して動的に作るので、記事が増減しても追随する。しかし件数は collect() の戻り値から印字され、find_duplicates() に入る前に確定している。除外は件数に現れない |
最後の行が効いています。件数テストで捕まえられないのは偶然ではなく、印字の位置と除外の位置が違うことから決まる構造です。「走査件数が減っていないから対象は縮んでいない」という推論は、この形には効きません。
これは 2-1 の「引数で対象を 1 記事に絞る」変異と同じ狙いを、package.json の外側ではなく判定関数の内側でやったものです。座標がもう 1 回動いています。
なお、この形を実際に PR で通そうとすれば、diff に説明のつかない 1 行が現れるのでレビューで見つかる可能性は高いです。ここで実証したのは「レビューを騙せる」ではなく、「機械的な保証はここまで」という境界の位置です。境界の外側を人間のレビューが担保しているなら、それは設計判断として書いておくべきものであって、「免除の器は作れない」と言い切ってよい状態ではありません。
4. なぜ対策 PR で起きやすいのか
5 件を並べて見えるのは、次の 3 つです。
4-1. 注意は「何を検出すべきか」に向き、「起動するか」は自明だと思われる
対策 PR を書いている人(あるいはエージェント)の頭の中は、検出ロジックの設計で埋まっています。どの入力を違反とみなすか、偽陽性をどう避けるか、既存データで落ちないか。そこには必ず検証が付きます。
一方、「その検出が起動するか」は自明に見えます。package.json に 1 行足した、workflow に step を足した。目で見て確認できるので、検証の対象だと認識されません。
5 件のうち 3 件(2-1 / 2-2 / 2-4)は、検出ロジック側には二面検証があり、起動側にだけ無かった、という形です。2-2 に至っては、書いた 17 テストのうち検出ロジックのテストは全部正しく書けていて、それが 1 つも走っていませんでした。残る 2 件(2-3 / 2-5)は起動はしていて、入力か判定の内側が当たっていない形です。
4-2. 検証手段そのものが検証されない
テストにはテストがありません。メタテストにメタメタテストはありません。どこかで再帰は止まります。
止める場所を決める方法は 1 つしかないと考えています。再帰を積むのではなく、「その変異を仕込んだら本当にガードが黙るか」を 1 回実行して確かめることです。これは無限後退しません。実行結果が終端になるからです。
spec/guard_self_check.md の R-5 として本リポジトリに入れたのはこの形です。PR #476 では「package.json から配線を外すと本テストが実際に FAILED になる」ことを実測してから merge しました。PR #491 の test_mutated_wiring_would_break_this_test は、無力化した npm script を実際に実行して、違反 fixture で exit 0 になる(=この変異は本当に無力化である)ことを実証しています。
4-3. 無力化の座標は 1 段ずつ移る(外へ 3 回・内へ 2 回・検証入力へ 1 回)
これが一番はっきりした規則性です。時系列に並べると階段になります。
| # | PR | 塞いだ座標 | 次に無力化できた座標 |
|---|---|---|---|
| 1 | #476(1 コミット目) | 検出ロジック(validate_code_references.py) | 外へ → 配線(package.json の値) |
| 2 | #476(2fc7427) | 配線の値(完全一致) | — |
| 3 | #480(1 コミット目) | 検出ロジック(check_ledger_entry_gate.py) | 外へ → 配線(test:guards への追加漏れ) |
| 4 | #480 | 検出分岐(3 通り) | 検証入力へ → 合成 fixture の語順(実データに当たらない) |
| 5 | #480 | CI の run: の中身 | 外へ → run: の外側の YAML(if: / on.paths / continue-on-error) |
| 6 | #480(修正後) | workflow の構造(yaml.safe_load)と実データ fixture | — |
| 7 | #491(初版) | 免除リストの識別子名 | 内へ → 判定関数の内部(振る舞いでは見えない位置) |
| 8 | #491(修正後) | frontmatter 由来の免除(振る舞い検査) | 内へ → 判定関数の入口の 1 行除外(3 節で再現、未対応) |
「外へ移る」だけではありません。 1・3・5 段目は外向き(実装 → 配線 → CI の外側の YAML)ですが、7・8 段目は内向き——判定関数の中へ——に戻っており、4 段目は外でも内でもなく検証入力の側へ動いています。固定した座標の隣が次の無力化の座標になる、というのが実際に起きたことで、方向は一定ではありません。これは処方に直結します。5 節 (3) の「外側を構造で固定する」は 1・3・5 段目には効きますが、本記事が唯一「未対応」と言っている 8 段目には効きません。外側だけ固めて終わったことにはできません。
しかも各段で、前の段の教訓は正しく適用されています。#480 は #476 の教訓(部分文字列一致で書かない)を守って run: を実行しました。#491 は #480 の教訓(run: の外側も見る)を守って workflow を yaml.safe_load で読みました。それでも次の段が残りました。
一段ずつ潰していく限り、この階段は終わりません。終わらせる代わりにできるのは、「いまどの段まで固定したか」を書いておくことだと考えています。#491 の guard は本体の docstring に「本ガードが無くせないのは slug 単位の恒久免除までである。ラベル 1 枚による PR 粒度の免除は残る」と書いており、これは正しい書き方です。3 節で見つけた段が、そこに追記されるべき次の 1 行です。
5. 自分のリポジトリでやること
抽象的な教訓ではなく、そのまま実行できる形にします。ガードや lint ルールを追加・修正する PR で、merge 前に 3 つやります。
(1) 変異を 1 つ仕込んで、テストが赤くなるかを見る
最も安いのはこれです。ガードを「起動しない形」に書き換えて、テストスイートを回します。
# 例: npm script の配線を無力化する
# 元: "test:mycheck": "python3 scripts/mycheck.py"
# 変異: "test:mycheck": "python3 scripts/mycheck.py || true"
pnpm test; echo "exit=$?"
exit が 0 のままなら、あなたのテストは配線を守っていません。 元に戻して、配線の値を完全一致で固定するテストを足します。
変異は 4 種類だけ試せば十分だと考えています。2-1 の表がそのまま使えます。echo への差し替え / || true / コメントアウト / 引数で対象を縮める。
(2) 「何本が対象になったか」をガード自身に出力させ、それをテストで固定する
対象 0 件を「違反 0 件」と同じ出力・同じ終了コードで報告するガードは、走査パスが変わった瞬間に静かに全通しになります。本リポジトリでは spec/guard_self_check.md の R-6 規約 5 としてこれを禁止し、--require-target を付けると 0 件で非 0 を返すようにしています。
ただし 3 節で見たとおり、件数の出力位置が判定より前だと、判定側の除外は件数に現れません。件数テストを足すときは、その件数がどの時点の値かを確かめてください。
(3) 外側は構造で固定し、内側は diff で見る(2 本立て)
run: の文字列を実行するだけでは足りません。GitHub Actions なら最低限この 3 つです。
on.pull_request.paths/paths-ignoreが無いこと- job の
if:が想定の式と完全一致すること - step に
continue-on-error/ifが無いこと
YAML を構造として読んで assert します。on キーは YAML 1.1 で真偽値に解釈されるので、wf.get("on", wf.get(True)) のように両方見る必要があります。
ただしこれで塞がるのは外向きの座標だけです。 4-3 の 7・8 段目のように、無力化が判定関数の内側へ入ると、外側の構造検査には一切現れません(3 節の再現では走査件数すら動きませんでした)。内側に対して機械的に効く検査は本記事の時点で用意できていないので、判定関数の入口(引数を受け取ってから最初の分岐まで)に加わった行は、diff レビューで人が見るという運用にしています。「機械で保証できるのはここまで、ここから先は人が見ている」を明示するのが (4) です。
(4) 保証の境界を、guard 本体に書く
「このガードは何を保証しないか」を docstring に書きます。書けないなら、境界を把握していないということです。
6. 限界と、やらないほうがいい場合
この記事が言えていないこと。 n=5、同一リポジトリ、同一週、書き手も同一です。他組織で同じ頻度で起きるとは言えません。言えるのは「この型が実在する」ことと、「5 件が同じ階段の上に並んでいた」ことだけです。
やらないほうがいい場合。 ガードの数が少なく、CI の実行時間が短く、変更頻度も低いリポジトリでは、ここまでのメタ検証はコストが見合いません。本リポジトリで必要になったのは、記事生成をエージェントに任せて検査を積み増した結果、検査スクリプト(ls scripts/ | grep -cE '^(check|validate|audit)_' で 15 本)が増え、人間が全部の起動経路を把握できなくなったからです。
やりすぎの兆候。 メタテストのメタテストを書き始めたら止めてください。4-2 に書いたとおり、再帰は終端を持ちません。終端は「変異を 1 回実行する」ほうにあります。
配線の完全一致固定の副作用。 値を完全一致で縛ると、正当な変更(引数の追加、別インタプリタへの切り替え)でもテストが落ちます。落ちること自体は設計どおりですが、テストの期待値を機械的に更新して通す運用になった時点で、このテストは何も守らなくなります。期待値を更新するときは、変異でないことを人が確認する手順とセットにしてください。
まとめ
- 「ガードが発火しない」欠陥を塞ぐ PR が同じ型の欠陥を作り込んだ実例が、2 日間で 5 件ありました(
origin/main=a184564、2026-09-10 時点。#480 だけで 3 件)。 - 原因は不注意ではありません。無力化できる座標が、実装 → 配線 → CI の
run:→run:の外側の YAML → 判定関数の内部、と 1 段ずつ移ります。外へ 3 回・内へ 2 回・検証入力へ 1 回で、一方向ではありません。 各段で前の段の教訓は正しく守られていました。 - 最後の 1 件はいまも残っています。判定関数の入口に 1 行足すと、実在の title 重複がある状態でガードが
exit 0になり、自己検証 191 テストが全緑でした。内向きの座標なので、外側を固定する処方では届きません。 - 持ち帰る操作は 1 つです。ガードを直したら、起動しない形に 1 行書き換えて、テストが赤くなるかを見る。 赤くならなければ、守られているのは検出ロジックだけです。
関連記事として、ある時点の穴の分類は 効いていないガードを見つける7つの型 に、検査を積み増した結果どこまで増えるかは AI Harnessの技術的負債と削る基準 にまとめています。
FAQ
Q1. 「対策 PR は同じ穴を掘りやすい」は一般的に成り立ちますか
この記事のデータからは言えません。n=5、同一リポジトリ、同一週、同一の書き手です。しかも「対策 PR に同じ欠陥があるか」という問いを持って探した結果なので、選択バイアスがかかっています。主張できるのは「実在する」と「5 件が同じ階段の上に並んでいた」までで、移動の方向すら一定ではありません(外へ 3 回・内へ 2 回・検証入力へ 1 回)。
Q2. メタテストのメタテストを書くべきですか
書かないほうがよいと考えています。再帰は終端を持ちません。代わりに「その変異を仕込んだら本当にガードが黙るか」を 1 回実行してください。実行結果が終端になります。本リポジトリでは spec/guard_self_check.md の R-5 としてこの手順を規約にしています。
Q3. pnpm test が exit 0 なら、テストは走っていますか
いいえ。exit 0 が意味するのは「起動した検査がすべて通った」だけです。配線されていないテストモジュールは失敗しません。本リポジトリでは、新規 17 テストが pnpm test からも CI からも一度も起動しないまま PR が緑になった実例があります(PR #480)。走ったことを確かめるには、テスト数やモジュール名を出力させて突き合わせるか、配線の値をテストで固定します。
Q4. GitHub Actions で run: を検証すれば十分ですか
足りません。on.pull_request.paths を足す / job に if: ${{ false }} を書く / step に continue-on-error: true を付ける、のいずれでも run: を 1 文字も変えずにジョブを無効化できます。YAML を構造として読み、外側も完全一致で固定してください。on は YAML 1.1 で真偽値 True に解釈されるため、キーの読み方に注意が必要です。
Q5. 免除リスト(baseline)を持たない設計は安全ですか
「免除が stale 化する経路が生まれない」という利点は実在します。ただし「免除の器を作れない」ことを機械的に保証するのは別問題です。識別子名の走査は素通りしますし、振る舞い検査へ直しても、判定関数の入口で実在 slug を 1 行除外する形は本記事の 3 節時点で素通りします。保証できる範囲を guard 本体に書き、その外側は人のレビューが担保していると明示するのが現実的です。
References
- GitHub Actions — Workflow syntax(
on.paths/jobs.<id>.if/continue-on-errorの仕様) - YAML 1.1 — Boolean type(
onが真偽値に解釈される根拠) - PyYAML Documentation(
safe_loadの挙動) - Python 3 —
unittest(subTestによる変異の列挙) - PIT Mutation Testing(変異を埋め込み、テストが kill できるかを測る)
- Practical Mutation Testing at Scale: A view from Google(arXiv:2102.11378)(大規模適用の報告)
- Martin Fowler — TestCoverage(カバレッジ率を目標値にすることへの警告)
- Google SRE Book — Testing for Reliability(試していない復旧手段は動く確信を与えない)
- git — git-archive(使い捨てコピーの作り方)
