Markdown の作業記録ファイルに、Node.js のスクリプトで、作業のたびに数行ずつ差し込んでいます。差し込み方は素朴で、ファイルの中のある文字列(目印)を text.replace(目印, 目印 + 新しい行) で置き換えるだけです。目印は見出しのこともあれば、既にある項目の行のこともあります。
この書き方で、記録ファイルの先頭から、置き換えで一致した箇所の手前までが、差し込んだ行の途中にそっくり複製されることが3回ありました。差し込んだ行には、Markdown のコード表記の中で $ の直後に閉じのバッククォートが来る並びがあっただけです。エラーは1つも出ず、ファイルは少し長くなっただけでした。
この記事は、その3回を git の履歴から数えた結果と、同じ形を最小の入力で再現した結果だけを書きます。
1. 何が起きるか
String.prototype.replace の第2引数に文字列を渡すと、その文字列の中の $ で始まるいくつかの並びが特別な意味を持ちます。そのうちの1つが $`(ドル記号とバッククォート)で、**「一致した箇所より前の全文」**に置き換わります。
差し込みたかった行は、たとえばこうです。
- 今日の作業: PowerShell の `$` が bash に展開された
この行の中の「` $ `」の部分のうち、$ とその直後のバッククォートが組になって $` と読まれます。一致した箇所(ここでは目印)より前の全文、つまり**ファイルの先頭がそこへ入ります。**閉じのバッククォートは消費されるので、残るのは開きのバッククォート1つと、先頭の全文です。
2. 履歴で数えた3回
記録ファイルを変えた動かないコミット279個を1つずつ取り出し、「その版の先頭(1行目から目印の見出しの手前まで)が、先頭以外の位置にも現れる回数」を数えました。本文は保存していません。
| コミットの時刻 | 先頭以外に現れた回数 |
|---|---|
| 2026-09-03 07:31 | 0 → 1 |
| 2026-09-23 07:29 | 1 → 6 |
| 2026-09-23 13:35 | 6 → 0(個別に直した) |
| 2026-10-17 07:20 | 0 → 1 |
| 2026-10-17 13:09 | 1 → 0(個別に直した) |
- 1回目は、直すまで約20日間・91個のコミットに残っていました(1か所のままだったのが89個、2回目と重なったのが2個)。複製された長さは151文字です。同じ20日間にファイルは3,210行から7,435行へ伸びていたので、151文字の増え方は目に付きませんでした
- 2回目は同じ朝に5か所へ一度に入りました。複製された長さは5か所とも2,007文字です。1回目より長いのは、置き換えで一致した箇所がファイルのもっと後ろだったからです(
$`は「一致した箇所より前の全文」なので、どこで一致したかで長さが変わります) - 3回目は2026-10-17の朝で、同じ日の昼に気づいて戻しました。複製された長さは159文字です
長さは、改行を LF にそろえたうえで、複製が始まった位置からファイルの先頭と一致し続ける文字数で数えました。
2回目のときに気づいたのは、別の道具がそのファイルを読んで、同じ項目が5回出てくる一覧を出したからです。そのとき1回目の複製も一緒に見つかりました。差し込みに使ったスクリプトは残していないので、どう終わったかは記録にありません。
3. 最小の再現
依存なし・ファイルを読み書きしない・通信しないスクリプトです。バッククォートとドル記号は文字コードから組んでいます(シェルや別の文字列の中を通すと、そこでまた別の変換を受けるためです)。
// 記録ファイルの決まった位置に1行を差し込む、という素朴な処理を3通りで書き、
// 差し込む行に Markdown のコード表記(バッククォートで囲んだドル記号)が入っていたとき何が起きるかを数える。
// 依存0・ファイルを読み書きしない・通信しない。バッククォートとドル記号は文字コードから組む。
const BQ = String.fromCharCode(96);
const D = String.fromCharCode(36);
const HEAD = "# 作業記録\n\n更新規約: 着手したら必ず書く。\n\n";
const DOC = HEAD + "## 進行中\n\n- 昨日の作業\n";
const ANCHOR = "## 進行中\n\n";
// 差し込みたい1行。PowerShell の変数記号をコード表記で書いただけの、ありふれた文
const LINE = "- 今日の作業: PowerShell の " + BQ + D + BQ + " が bash に展開された\n";
const intended = DOC.split(ANCHOR).join(ANCHOR + LINE);
const ways = {
"replace(文字列, 文字列)": () => DOC.replace(ANCHOR, ANCHOR + LINE),
"replace(文字列, 関数)": () => DOC.replace(ANCHOR, () => ANCHOR + LINE),
"split / join": () => DOC.split(ANCHOR).join(ANCHOR + LINE),
};
const rows = Object.entries(ways).map(([way, f]) => {
const got = f();
let copies = 0, i = got.indexOf(HEAD, 1);
while (i !== -1) { copies++; i = got.indexOf(HEAD, i + 1); }
return { way, asIntended: got === intended, lengthGrowth: got.length - intended.length, headCopiedInto: copies };
});
// 置換文字列で特別な意味を持つ並び(同じ1か所の置換・捕獲なしの文字列検索)
const S = "abc-XYZ-def";
const pats = [D + D, D + "&", D + BQ, D + "'", D + "1", D + "<n>"];
const patterns = pats.map((p) => {
const got = S.replace("XYZ", p);
return { replacement: p, result: got, asWritten: got === "abc-" + p + "-def" };
});
console.log(JSON.stringify({ node: process.version, insert: rows, patterns }, null, 2));
Node v24.18.1 で2回流し、出力は1バイトも同じでした。
| 差し込み方 | 意図どおりか | 意図より長くなった文字数 | 先頭が複製された回数 |
|---|---|---|---|
replace(文字列, 文字列) |
いいえ | 24 | 1 |
replace(文字列, 関数) |
はい | 0 | 0 |
split / join |
はい | 0 | 0 |
24 は、複製された先頭(26文字)から、食われた2文字($ と閉じのバッククォート)を引いた数です。
置換文字列そのものの規則も、同じ実行で6通り見ました。検索に使ったのは文字列(正規表現ではない・捕獲なし)です。
| 置換文字列 | 結果 | 書いたとおりに入ったか |
|---|---|---|
$$ |
abc-$-def |
いいえ(ドル記号1つになる) |
$& |
abc-XYZ-def |
いいえ(一致した文字列そのもの) |
$` |
abc-abc--def |
いいえ(一致箇所より前の全文) |
$' |
abc--def-def |
いいえ(一致箇所より後ろの全文) |
$1 |
abc-$1-def |
はい(この形では捕獲が無いので文字のまま) |
$<n> |
abc-$<n>-def |
はい(この形では名前つき捕獲が無いので文字のまま) |
下の2つが「はい」なのは、この測り方では捕獲が無いからです。正規表現で捕獲を使えば、$1 も $<n> も置き換わります(仕様にそう書かれていますが、この記事では測っていません)。
4. 静かだった理由
- **例外が出ない。**置き換えの規則どおりに動いただけなので、スクリプトは正常に終わります
- **ファイルは壊れない。**Markdown として読めるまま、少し長くなるだけです
- **差し込む行が
$`を含むことはまれ。**ふだんは何も起きないので、書き方を疑う理由がありません。3回とも、差し込んだ行にバッククォートで囲んだ$の表記がありました(正規表現の末尾の$と、変数記号の$)
5. やめたこと
- **置き換える中身が自分の書いた文章のときは、
replaceに文字列を渡さない。**関数を渡すか、split/joinにします。関数の戻り値には$の規則が当たりません - 文字列のまま
$を$$に置き換えてから渡す方法もありますが、置き換えを忘れる場所が1つあれば同じことが起きるので、私は採っていません
6. 測っていないこと
- 測ったのは Node v24.18.1 の1台だけです。ブラウザや他の版では走らせていません
-
replaceAllと、正規表現で捕獲を使う形は測っていません - 3回のほかに、先頭以外の部分(一致箇所より後ろ・一致した文字列)が複製された回があるかは数えていません。数えたのは「ファイルの先頭がもう1つ現れたか」だけです
- 2回目に5か所へ一度に入った理由は、当時の差し込みスクリプトを残していないので確かめていません(5か所とも同じ2,007文字なので、1回の置き換えの中に
$`が5つあったと考えるのが自然ですが、確かめてはいません) - 複製された長さは「先頭と一致し続ける文字数」で数えたので、複製の直後の文字がたまたま先頭の続きと同じなら、その分だけ長く数えます。3回目の159文字は、見出し(151文字)とその直後の空行までを含んだ長さで、複製の直後の文字は先頭の続きと違っていました。ただし、どこで一致したのかを差し込みスクリプトから確かめたわけではありません
記録ファイルが20日で4,000行以上伸びていくと、1か所151文字の増え方は目に入りません。気づけたのは、2回目に5か所へ一度に入り、ファイルの中身を別の道具で一覧にしたときに同じ項目が5回出てきたからでした。
私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html