実行ログを開いたら、書いたはずの「コスト: $2 / MTok」が「コスト: / MTok」になっていました。金額だけがきれいに消えています。
隣の行では、記録用に貼っておいたコマンドの文字列が、そのコマンドの実行結果に置き換わっていました。さらに数えてみると、7行書いたはずのログが6行しかありません。
犯人はスクリプトのどこにもいませんでした。強いて言えば、cat << EOF の EOF にクォートが付いていない、その一点です。
直し方だけ先に書きます
区切り語をクォートで囲みます。差分はこれだけです。
# 変更前
cat << EOF > log.txt
コスト: $2 / MTok
EOF
# 変更後
cat << 'EOF' > log.txt
コスト: $2 / MTok
EOF区切り語(この例では EOF)にクォートが付いていない heredoc は、本文をシェルの展開対象として扱います。付いていれば、本文は1文字も解釈されずにそのまま渡ります。
急いでいる方はここで戻っていただいて大丈夫です。以下は、何がどう書き換わるのかを実際に測った記録と、値を埋めたい場合の書き方、既存スクリプトの棚卸し手順です。
未クォートの heredoc が、実際に何を書き換えたか
7行のログを書くつもりで、次のスクリプトを走らせました。DATE にはあらかじめ 2026-09-01 を入れてあります。
DATE="2026-09-01"
cat << EOF > a2.txt
日付: $DATE
生成コマンド: `date -u +%Y-%m-%dT%H:%M`
バックアップ先: /var/backup/$USER/
コスト: $2 / MTok
正規表現: ^\d{4}-\d{2}$
継続行: 末尾のバックスラッシュ \
次の行
EOF出力された a2.txt は次のとおりでした。
日付: 2026-09-01
生成コマンド: 2026-09-01T06:06
バックアップ先: /var/backup/masaki/
コスト: / MTok
正規表現: ^\d{4}-\d{2}$
継続行: 末尾のバックスラッシュ 次の行
wc -l は 6 を返しました。7行のつもりが6行です。書いた内容と出た内容を並べます。
| 書いた内容 | 出力された内容 | 起きたこと |
|---|---|---|
| 日付: $DATE | 日付: 2026-09-01 | 変数が展開された(意図どおり) |
| 生成コマンド: バックティックで囲んだ date | 生成コマンド: 2026-09-01T06:06 | コマンドが実行され、結果に置換された |
| /var/backup/$USER/ | /var/backup/masaki/ | 環境変数が展開された |
| コスト: $2 / MTok | コスト: / MTok | 位置パラメータ $2 として解釈され、空文字になった |
| 正規表現: ^\d{4}-\d{2}$ | 正規表現: ^\d{4}-\d{2}$ | そのまま残った(後述) |
| 末尾がバックスラッシュの行+次の行 | 1行に連結 | 行継続として食われ、行数が1減った |
個人開発で書いたスクリプトを自分でレビューしている身としては、一番怖いのは4行目です。$2 は「2ドル」ではなく「2番目の引数」として読まれ、引数がないので空文字に置き換わりました。エラーは出ません。料金の記録が静かに空欄になるだけです。私が最初に気づいたのもここでした。
2行目も性質が悪いところです。「後で見返すためにコマンドを控えておく」という、ログでは一番よくある書き方が、そのままコマンドの実行になります。書いた覚えのない値が入っているのに、スクリプトを読み返しても実行している箇所が見つかりません。
一方で5行目の \d や末尾の $ は無傷でした。未クォート heredoc でバックスラッシュが効くのは $・バックティック・バックスラッシュ・改行の直前だけで、それ以外は素通りします。「一部は壊れて一部は残る」ので、目視レビューでは見落とします。
クォートの書き方は3通りあり、結果はすべて同じでした
$HOME とバックティックを1行に入れた本文で、区切り語の書き方だけを変えて比べました。
| 書き方 | 出力 |
|---|---|
| << EOF | /home/masaki|RUN(展開・実行される) |
| << 'EOF' | そのまま(展開されない) |
| << "EOF" | そのまま(展開されない) |
| << \EOF | そのまま(展開されない) |
シングルクォート・ダブルクォート・バックスラッシュのどれでも結果は同じです。区切り語の一部でもクォートされていれば、本文全体がリテラルになります。
ここは通常のシェルの感覚と食い違うところです。ダブルクォートは普段なら変数を展開しますが、heredoc の区切り語に限っては「クォートされているか否か」だけが判定されます。私はこの記法を 'EOF' に統一しています。読み手が「ここは展開されるのか」と一瞬でも考えなくて済むからです。既存コードで "EOF" を見かけても直す必要はありません。
<<- を使うとタブによる字下げを剥がせます。剥がれるのはタブだけで、スペースは残りました。エディタがタブをスペースに変換する設定になっていると、この機能は静かに無効になります。
値を埋めたいときは、プレースホルダを置いて後から差し込む
「変数を展開したいから未クォートにしている」場合もあります。ただ、埋めたい値が2つ3つあるだけなら、本文をリテラルに保ったまま後から差し込むほうが安全です。
DATE="2026-09-01"; SLUG="my-article"
cat << 'EOF' > tmpl.txt
日付: __DATE__ / slug: __SLUG__
コスト: $2 / MTok
EOF
sed -e "s/__DATE__/${DATE}/" -e "s/__SLUG__/${SLUG}/" tmpl.txt出力は次のとおりです。
日付: 2026-09-01 / slug: my-article
コスト: $2 / MTok
埋めたい2つだけが埋まり、$2 は文字として残りました。本文に何が入っていても壊れないので、ログのように「中身が事前に読めないテキスト」を書き出すときはこの形にしています。
ただし、この方法には二次災害があります。差し込む値にスラッシュが含まれていると sed が転びます。
P="/var/log/app"
sed "s/__P__/${P}/" <<< "path: __P__"
# => sed: -e expression #1, char 10: unknown option to `s's コマンドの区切り文字は自由に選べるので、パスを埋めるときは | などに変えます。
sed "s|__P__|${P}|" <<< "path: __P__"
# => path: /var/log/appパスを埋める用途では区切りを | に固定しておくのが無難です。値の側に | が来る可能性まで含めて考えるなら、sed ではなく python3 や awk に渡したほうが確実です。
手元のスクリプトを棚卸しする
一度でも踏んだなら、他にも同じ書き方が残っているはずです。未クォートの heredoc のうち、本文に展開対象を含むものだけを拾う小さなスクリプトを書きました。
import re, sys, pathlib
OPEN = re.compile(r"<<-?\s*(?P<q>['\"\\])?(?P<w>[A-Za-z_][A-Za-z0-9_]*)")
RISK = re.compile(r"`|\$\(|\$[A-Za-z_{0-9]|\\$")
def scan(path):
lines = pathlib.Path(path).read_text(errors="replace").splitlines()
hits, i = [], 0
while i < len(lines):
m = OPEN.search(lines[i])
if m and m.group("q") is None:
word, start = m.group("w"), i
body = []
i += 1
while i < len(lines) and lines[i].strip() != word:
body.append(lines[i]); i += 1
risky = [b for b in body if RISK.search(b)]
if risky:
hits.append((start + 1, word, len(body), len(risky)))
i += 1
return hits
total = 0
for p in sys.argv[1:]:
for ln, w, n, r in scan(p):
total += 1
print(f"{p}:{ln}: 未クォート heredoc({n}行中 {r}行に展開対象)")
print(f"検出: {total} 件")
sys.exit(1 if total else 0)区切り語がクォートされているものは最初から除外し、本文にバックティック・$(・$変数・末尾バックスラッシュのいずれも含まないものも落とします。展開対象がなければ、未クォートでも実害がないためです。
このスクリプトを、手元の Linux 環境にあるシェルスクリプト272本(/usr/bin・/usr/sbin・/etc 配下)に当ててみました。
| 項目 | 件数 |
|---|---|
| 走査したシェルスクリプト | 272 本 |
| heredoc を含むファイル | 60 本 |
| 検出された未クォート heredoc | 138 箇所(40 ファイル) |
| クォート済みの heredoc | 9 箇所 |
ここで正直に書いておきたいのは、138箇所の大半はバグではないということです。設定ファイルを生成するスクリプトは変数を展開したくて未クォートにしているので、検出されて当然です。このスクリプトが出すのはバグ一覧ではなく、目を通す価値のあるレビュー対象の一覧です。
それでも役には立ちます。行番号と「本文の何行に展開対象があるか」が並ぶので、設定生成なのかログ出力なのかは開いた瞬間に判断できます。私は自動実行するスクリプトを更新するとき、この出力を一度眺めてから push するようにしました。
同じ「エラーが出ないまま結果だけが変わる」種類の落とし穴は他にもあって、settings.json のキー名を1文字間違えても、Claude Code は何も言わずに無視します で扱った設定キーの誤りも同じ構図でした。シェルの引用符が絡む失敗をもう一段深く追いたい方には、空白を含むパスで一括処理が静かに0件になる過程を測った空白ひとつで、80件の検査が0件になるが近いところにあります。
なぜ AI に書かせると未クォートで返ってくるのか
今回の元のスクリプトは、私が Claude Code に「ログを書き出す処理を足して」と頼んで返ってきたものでした。返答は素直な cat << EOF です。
理由は測った数字がそのまま示していると考えています。272本のスクリプトに含まれていたクォート済みの heredoc は9箇所、未クォートは138箇所でした。世の中のシェルスクリプトにおける多数派は未クォートで、学習した側がその比率を反映するのは自然なことです。人間が書いても同じ比率になるはずで、これはモデルの欠点というより、シェルの既定値が「展開する」側に倒れていることの帰結です。
なので、頼み方を一言変えるだけで結果が変わります。
ログを書き出す処理を足してください。
heredoc は必ず <<'EOF' の形(区切り語をクォート)で書き、
値を埋める必要がある箇所は __PLACEHOLDER__ を置いて sed で差し込んでください。
この一文を入れてからは、生成されるスクリプトの形が安定しました。プロジェクトの設定ファイルやスキルの手順書に書いておけば、毎回頼まなくても済みます。
今日できる一手は、cat << EOF で始まる行を手元のスクリプトから検索して、ログやメッセージを書き出しているものだけ 'EOF' に変えることです。設定生成のものは触らなくて構いません。それだけで、金額や日付が静かに書き換わる経路はひとつ閉じます。
私自身、この挙動は何度か読んだことがあったはずなのに、実際にログが1行短くなるまで自分の問題として認識できていませんでした。同じ場所で手を止めた方の役に立てば嬉しいです。お読みいただきありがとうございました。