差分の途中でスクロールが止まりました。8月12日の 2.8.0 で、ファイルビューアの表示サイズと履歴上の大きな差分の表示量に上限が入ったためです。大きなコマンド出力を含む会話でのクラッシュ修正とセットで入った変更で、安定性の面では歓迎すべきものです。
ただ、その日の私は「エージェントが触った範囲を最後まで見る」という前提で作業していました。上限に当たった瞬間、その前提が実は一度も検証されていなかったことに気づきます。全部見えていたときの私は、本当に全部見ていたのでしょうか。
この記事は、その問いに答えるために組んだレビュー手順の記録です。8月に書いた版を11月に同じ入力で走らせ直したところ、私のスクリプト自体に穴が三つ見つかりました。その直し方も含めて、いま手元で使っている形をそのまま載せています。
全部見えていた頃に、見落としていたもの
答え合わせはすぐにできました。個人開発で運用している技術ブログのリポジトリには、日本語記事だけで 1,006 本の MDX が入っています(8月時点)。そこに、コードブロック内の不要なバックスラッシュエスケープがどれだけ残っているかを数えてみました。
grep -rc '\\!' content/articles/ja --include='*.mdx' \
| awk -F: '$2>0{n++; s+=$2} END{print "該当ファイル:", n+0, "/ 総出現数:", s+0}'
# => 該当ファイル: 28 / 総出現数: 130
28 ファイル、130 行。中身はこういうものです。
// 公開されていた行
if (\!researchOutput.sources || researchOutput.sources.length === 0) {
\! は JavaScript として不正です。読者がこれをコピーすれば、その場で構文エラーになります。シェル経由でファイルを書いたときにヒストリ展開の対策として入ったバックスラッシュが、そのまま残ったものでした。
これらの行は、一度も隠れていませんでした。私は commit のたびに差分を眺めていて、その画面を 130 回通過しています。つまり表示上限は、見えなくしたのではなく、見ていたつもりだった部分を可視化しただけでした。
目で追う量は、変更量に比例して増える
なぜ通り過ぎたのかを考えると、構造の問題に行き当たります。
記事1本の追加は、日本語版と英語版で 2 ファイルです。このリポジトリの記事は中央値で 211 行、最大で 1,265 行ありました。1 本追加するだけで、差分は 400 行から 2,000 行になります。ここに既存記事の修正が混ざれば、さらに増えます。
人が集中して読める行数は、変更量に比例しては増えません。増えるのは「読んだつもりになる速度」のほうです。\! のような 2 文字の異物は、この速度の中でもっとも消えやすい種類のものでした。
表の上半分を人間が担当していたことが、そもそもの配分ミスでした。上限が入ったのは、その配分を変える合図だったのだと受け取っています。
数えるのは機械に、疑うのは私に。 この分け方を決めてから、レビューの手触りがはっきり変わりました。
差分を「要約」と「全量」に分ける
切り替えた方針は単純です。差分をそのまま人に渡すのをやめ、次の2つに分けます。
- 要約 — 画面に収まる固定長のもの。機械で判定できたことと、人が見るべき場所の入口だけを載せます
- 全量 — ファイルとして保存するもの。捨てはしませんが、既定では開きません
要約が固定長であることが要です。変更が 400 行でも 4,000 行でも、最初に読むものは同じ長さになります。レビューに使う時間が、変更量から切り離されます。
以下が、11月の再検証を経たいまの版(v2)です。8月の版との違いは、コメントの 1)〜3) の三か所にあります。
#!/usr/bin/env bash
# review-digest.sh v2 — 差分を「1画面の要約」と「全量アーティファクト」に分ける
# 使い方: ./review-digest.sh 未コミットの変更(新規ファイルを含む)
# ./review-digest.sh origin/main 指定リビジョンとの差分(新規ファイルを含む)
# macOS 標準の bash 3.2 を想定し、配列と mapfile は使っていません
set -uo pipefail
BASE="${1:-HEAD}"
OUT_DIR="${REVIEW_OUT_DIR:-.review}"
mkdir -p "$OUT_DIR"
FULL="$OUT_DIR/diff-full.patch"
NEW_LIST="$OUT_DIR/untracked.lst"
# 1) 未追跡ファイルを一時的に intent-to-add にして、差分に載せる
git ls-files --others --exclude-standard -z -- . ":(exclude)$OUT_DIR" > "$NEW_LIST"
if [ -s "$NEW_LIST" ]; then
xargs -0 git add -N -- < "$NEW_LIST"
# 2) 終了時に必ず元へ戻す(stash や commit -a を巻き込まないため)
trap 'xargs -0 git reset -q -- < "$NEW_LIST"' EXIT
fi
git diff "$BASE" > "$FULL"; STATUS=$?
if [ "$STATUS" -ne 0 ]; then
echo "git diff が失敗しました (exit=$STATUS)" >&2
exit "$STATUS"
fi
STAT=$(git diff --stat "$BASE" | tail -1)
NUMSTAT=$(git diff --numstat "$BASE")
# 3) 追加行だけを取り出し、先頭の + を外してから検査する
ADDED=$(grep -E '^\+' "$FULL" | grep -vE '^\+\+\+ (b/|/dev/null)' | sed 's/^+//' || true)
E_BANG=$(printf '%s\n' "$ADDED" | grep -c '\\!' || true)
BARE=$(printf '%s\n' "$ADDED" | grep -E '(^|[[:space:]])https?://' \
| grep -vE '\]\(|<https?://|"https?://' | grep -c . || true)
MDTBL=$(printf '%s\n' "$ADDED" | grep -cE '^[[:space:]]*\|[-: |]+\|[[:space:]]*$' || true)
NEWCOUNT=$(tr -cd '\0' < "$NEW_LIST" | wc -c | tr -d ' ')
echo "== 変更規模 =="
echo " ${STAT:- (変更なし)}(うち新規ファイル ${NEWCOUNT} 本)"
echo
echo "== 機械検出(全量を開く前にここを直す)=="
echo " 不要な \\! エスケープ : ${E_BANG} 行"
echo " 裸URL : ${BARE} 行"
echo " Markdown テーブル区切り: ${MDTBL} 行"
echo
echo "== 目視が要る箇所(変更量の多い順)=="
printf '%s\n' "$NUMSTAT" | sort -rn | head -5 | while read -r add del path; do
[ -z "${path:-}" ] && continue
printf ' %5s+ %5s- %s\n' "$add" "$del" "$path"
done
echo
echo "全量: $FULL ($(wc -l < "$FULL" | tr -d ' ') 行)"
# 検出があれば非ゼロで終える
[ "${E_BANG:-0}" -eq 0 ] && [ "${BARE:-0}" -eq 0 ] && [ "${MDTBL:-0}" -eq 0 ]
3種類の欠陥を1行ずつ含む新規ファイルを置いて走らせると、こう出ます(2026年11月・Ubuntu 22.04 / git 2.34.1 / bash 5.1 で実行)。
== 変更規模 ==
1 file changed, 3 insertions(+)(うち新規ファイル 1 本)
== 機械検出(全量を開く前にここを直す)==
不要な \! エスケープ : 1 行
裸URL : 1 行
Markdown テーブル区切り: 1 行
== 目視が要る箇所(変更量の多い順)==
3+ 0- _probe.md
全量: .review/diff-full.patch (9 行)
終了コードは 1 で、実行後の git status --short は ?? _probe.md のまま、つまり実行前と同じ状態に戻っていました。12 行で状況が分かり、直すべき場所も分かります。
最初の版は、何も検出しませんでした
8月にこのスクリプトの原型を書いて最初に走らせたとき、3 つとも 0 と表示されました。欠陥を含むファイルを自分で置いた直後にです。
原因は git diff HEAD にあります。未追跡ファイルは差分に現れません。エージェントが新しいファイルを作った場合、その中身は丸ごと検査の対象外になります。新規作成こそがもっとも検査したい変更なのに、既定では素通りする構造でした。
対策は intent-to-add(git add -N)です。ファイルの存在だけを index に登録すると、内容が追加行として差分に載るようになります。
表の下の二行は、8月の時点では把握していませんでした。この点は次の節で詳しく書きます。
検出ゲートを書いたつもりで、実際には何も見ていない状態は起こり得ます。ゲートを足したときは、必ず落ちるはずの入力を一度通して、本当に落ちることを確かめたほうが安全です。読み込まれているのに一度も動いていない仕組みを、捨てのログで見分ける考え方は、フックが読み込まれているのに動かないときの切り分けでも同じ形で使っています。
11月の再検証で見つかった、残り三つの穴
8月の版を、同じ欠陥入りのファイルで走らせ直したのは11月のことです。結果は芳しくありませんでした。原因はすべて、私の書き方の側にありました。
穴1: 行頭の裸URLを 0 と数えていました
8月の版は、追加行を + 付きのまま検査していました。裸URLの判定は「行頭か空白の直後に https:// がある」なので、+https://example.com という行では行頭が + になり、一致しません。
段落の途中に貼られた URL は拾えますが、URL だけの行——いちばんありがちな貼り方——を見逃していたことになります。8月に載せた出力例では裸URLも 1 行と数えていましたが、同じ入力で走らせ直すと 0 でした。出力例を書き写す段階で、確かめ直しが抜けていたのだと思います。
v2 では sed 's/^+//' で先頭の + を外してから検査します。テーブル区切りの判定も、+ を前提にしない正規表現に揃えました。
穴2: リビジョンを渡すと、新規ファイルが消えていました
8月の版は、git add -N を「引数なし」の分岐にだけ入れていました。./review-digest.sh origin/main のようにリビジョンを渡すと、未追跡ファイルは差分に載らないまま、終了コード 0 で通っていました。
エージェントに作業ブランチで書かせて origin/main と比べる使い方は、むしろこちらが本命でした。本命の呼び方で素通りしていたわけです。
穴3: intent-to-add を残したまま終えていました
8月の版には「ステージングはされないため、その後の git add の操作を邪魔しません」と書いていました。これは半分しか正しくありませんでした。
まず git commit -a です。intent-to-add のファイルは追跡対象として扱われるので、commit -a でそのまま commit に入ります。git の公式ドキュメント(git add の -N の説明)にも、git commit -a で commit できるようになることが用途として書かれています。検査のために置いた使い捨てのファイルが、気づかないうちに履歴に残り得るということです。
次に git stash です。手元の git 2.34.1 では、intent-to-add のファイルが残っていると次のように失敗しました。
error: Entry 'new2.md' not uptodate. Cannot merge.
Cannot save the current worktree state
この組み合わせは git のメーリングリストでも繰り返し報告されている挙動です。版によって振る舞いが違う可能性があるので、お手元の版では一度確かめていただければと思います。
v2 では、実行前に未追跡ファイルの一覧を untracked.lst に書き出し、trap ... EXIT で終了時に必ず git reset -q -- して元に戻します。スクリプトが途中で落ちても、検出で非ゼロ終了しても、index は実行前の状態に戻ります。
要約を head に繋ぐと、判定が消えます
もう1つ、運用に入れてから気づいた点があります。要約が長くなってきたので、呼び出し側でこう書いてしまいました。
./review-digest.sh | head -20 # ← 判定が失われます
パイプラインの終了コードは、既定では最後のコマンドのものになります。head は正常終了するため、スクリプトが 1 を返していても、呼び出し側には 0 が届きます。検出しているのに止まらない、という状態です。
ここで「呼び出し側にも set -o pipefail を書けば済む」と考えたくなりますが、手元で試すと別の問題が出ました。| head -1 のように途中で読むのをやめると、スクリプトの側が SIGPIPE で終わり、欠陥が 0 件でも 141 が返ります。止まるべきでないときに止まる、という逆向きの誤りです。この誤検知が起きる条件は、pipefail を足したら通るはずの検査が赤になった話で詳しく測っています。
要約を短く保つ責任はスクリプト側に置き、呼び出し側では素直に実行します。どうしても絞りたいときは、判定を先に確定させます。
if ./review-digest.sh > .review/digest.txt; then
echo "機械検出なし"
else
head -20 .review/digest.txt # 判定を取り出した後で絞る
exit 1
fi
出力を切り詰める操作は、見た目より多くのものを落とします。
症状から引く切り分け表
この手順を入れてから私が実際に当たった症状を、確かめ方と直し方つきでまとめました。上から順に疑うと早く当たります。
どこに置くか——比較と、私の選び方
「機械で先に潰す」という考え方は同じでも、置き場所はいくつか考えられます。私が試したもの・検討したものを並べます。
私はこの場面なら、要約スクリプトを手元の区切りで実行し、同じスクリプトを CI の最初のジョブにも置く形を選びます。手元では早く気づくため、CI では飛ばせない関門として。どちらか一方にすると、私の場合は手元の実行を忘れる日が必ず出ました。
よくある誤解を三つ
誤解1: 表示上限が入ると、差分の一部が失われる
上限がかかるのはビューアでの表示であって、差分のデータではありません。git diff の出力をファイルに落とせば、全量はそのまま手元に残ります。2.8.0 で変わったのは「どこまで見せるか」の既定値です。
誤解2: git diff HEAD なら新規ファイルも含まれる
含まれません。HEAD を付けても付けなくても、リビジョンを origin/main にしても、未追跡ファイルは差分に現れません。エージェントが新規に作ったファイルを検査したいなら、intent-to-add などで index に存在を知らせる必要があります。
誤解3: \! が見つかれば、すべて不具合である
シェルでは find . \! -name "*.bak" のように、\! が正しい書き方になる場面があります。手元で試すと、v2 はこの行も 1 件と数えました。要約の数字は「直す一覧」ではなく「見る一覧」です。0 でないときは該当行を grep で抜き、言語ごとに判断してください。私の記事リポジトリでは、JavaScript と TypeScript のコードブロックにある \! を不具合として扱っています。
人が見る対象を、意識的に減らす
要約が通ったあとに私が開くのは、変更量上位の 2〜3 ファイルだけです。そこで見るのは、機械が判定できない項目に限ります。
- 説明文と、その下のコードが同じことを言っているか
- 選んだ方法の理由が書かれているか
- 主張している挙動を、実際に確かめたのか
エージェントが書いたコードで危ないのは、構文が壊れているものではありません。構文は通るのに、説明と少しずれているもののほうです。前者は機械が確実に拾い、後者は人しか拾えません。役割を分けたあとのほうが、後者に気づく回数が増えました。全量を追っていた頃は、そこへ到達する前に集中力を使い切っていたのだと思います。
上位ファイルの選び方そのものを機械に任せるかどうかは、別の判断になります。変更量の多い順に並べるのが本当に効くのかは、差分を読む順番を機械で並べ替えるかを 106 コミットで確かめた記録で測っています。
表示上限との付き合い方
2.8.0 の上限は、履歴上の大きな差分とファイルビューアに効きます。全量が必要な場面がなくなるわけではありません。私はこの区別を、次のように運用へ落としています。
全量はファイルに残っているので、必要になったときに開けば済みます。上限があるのはビューアであって、データではありません。この区別がついてから、上限は制約というより、既定値の変更として受け取れるようになりました。
手元に入れるまでの3ステップ
導入は 15 分ほどで終わります。私自身がこの順で入れました。
1. スクリプトを置いて、出力先を .gitignore に足す
review-digest.sh をリポジトリ直下か scripts/ に置き、実行権限を付けます。全量パッチの保存先 .review/ は .gitignore に追加してください。v2 は .review/ を未追跡ファイルの一覧から外していますが、ignore しておかないと git status が毎回にぎやかになります。
chmod +x review-digest.sh
echo ".review/" >> .gitignore
2. 必ず落ちる入力を通して、落ちることと、元に戻ることを確かめる
ここを飛ばすと、前述の「何も検出しない検出ゲート」になります。3種類の欠陥を1行ずつ含む使い捨てファイルを置き、引数なしとリビジョン指定の両方で実行します。どちらも終了コードが 1 になり、実行後の git status に A が残っていなければ正常です。
printf 'if (\\!ok) {}\nhttps://example.com\n| --- | --- |\n' > _probe.md
./review-digest.sh > /dev/null; echo "no-arg exit=$?" # 1 になれば正常
./review-digest.sh HEAD > /dev/null; echo "HEAD exit=$?" # 1 になれば正常
git status --short _probe.md # ?? のままなら正常
rm _probe.md
3. 既存の品質チェックより前に置く
本番運用のパイプラインに組み込む場合は、重いチェック(テスト・ビルド・リンタ)より前に置きます。2 文字の異物のためにビルドを1回まわす必要はありません。
./review-digest.sh > .review/digest.txt || { cat .review/digest.txt; echo "機械検出あり。先に直します"; exit 1; }
npm run lint && npm test
既存のチェックを置き換えるのではなく、前段に足すだけで済みます。所要時間は差分の大きさに関わらずほぼ一定で、3,000 行の新規ファイルを置いた差分でも、手元では 0.1 秒かかりませんでした。
次にやること
もしお手元で似た要約スクリプトを使っているなら、まずは「リビジョンを渡した呼び方」で、欠陥入りの新規ファイルが本当に落ちるかを一度だけ確かめてみてください。私のスクリプトは、その呼び方で3か月素通りしていました。
検出項目はまだ 3 つしかなく、いまも一つずつ増やしている途中です。同じところでつまずいた方の工夫があれば、ぜひ知りたいと思っています。