運営している技術サイトの記事ファイルから、アフィリエイトの定型誘導文をまとめて削除しました。行を指定して消すだけの、sed 一行で済む作業です。終了コードは 0。差分を眺めて、消えるべき行が消えたことも確認しました。
破損に気づいたのは、それから数週間後です。
しかも、壊れていたのは私が消した行ではありませんでした。
全件成功したあとに、後から見つかったもの
同じリポジトリの記事ファイル 975 本を、いまあらためて機械的に検査した結果が次の表です。すべて、あの一括削除より後に混入したものです。
| 検出した症状 | 件数 | 原因 |
| 本文が文の途中で終わっている | 36 本 | 削除対象の直前にあった導入句だけが残った |
| 見出しは残っているのに中身が空 | 48 本 | 見出しの下の段落だけが削除条件に一致した |
| コードブロックが閉じていない | 2 本 | 閉じる側のフェンスが削除範囲に入った |
本文に \ + ! のエスケープが残存 | 36 本 | 別の一括書き込みで混入し、そのまま気づかれなかった |
48 本の「空の見出し」は、全部が同じ文言の見出しでした。段落を消す条件が、たまたまその見出しの直下だけに当たっていたわけです。見出しが残っているので、目次にも記事一覧にも普通に出てきます。開いて初めて何もないと分かる。
いちばん堪えたのは、この 4 種類のどれも、置換した瞬間には一切の兆候を出さなかったことです。
同じ1行の削除が、ファイルによって3通りに分かれます
この構造を最小の形で再現しました。Rork が出力するような Expo プロジェクトの断片を 4 つ用意して、legacy を含む行を消すだけの置換をかけます。実際にコードを整理するとき、誰もが一度は書く条件です。
for f in src/PaywallScreen.tsx src/LegacyBanner.tsx src/useCredits.ts locales/ja.json; do
sed -i '/legacy/Id' "$f"; echo "$f exit=$?"
done
結果はこうなりました。
src/PaywallScreen.tsx exit=0
src/LegacyBanner.tsx exit=0
src/useCredits.ts exit=0
locales/ja.json exit=0
4 ファイルすべて成功です。ところが中身は 3 通りに分かれていました。
無害だったもの — PaywallScreen.tsx の該当行は単なる // TODO: legacy analytics のコメントで、消えても何も起きません。
コンポーネントごと消えたもの — LegacyBanner.tsx は、スタイルオブジェクトの内側にこう書かれていました。
<View
style={{
// legacy spacing, keep until redesign
padding: 12,
}}
/>
消えたのはコメント 1 行だけです。それなのに、このファイルは中括弧の対応が 1 つ足りない状態になりました。理由は単純で、削除条件の大文字小文字を無視する指定(I)が、ファイル名由来の識別子 LegacyBanner を含む export function LegacyBanner() { の行にも当たっていたからです。開き括弧を持つ行が消え、閉じ括弧だけが残りました。
データとして壊れたもの — locales/ja.json が最悪でした。
{
"paywall.title": "すべての機能を解放",
"paywall.cta": "続ける",
}
末尾のキーが消えて、その手前の行のカンマが取り残されています。TypeScript ならビルド時に落ちますが、JSON は import の仕方によっては実行時まで露見しません。i18n の辞書やテーマ定義、app.config の付随ファイルは、この形の破損に対してもっとも無防備です。
辞書のキーが欠けたまま本番に出ると、画面には翻訳されていないキー文字列がそのまま並びます。App Store の審査で必ず弾かれる類の不具合でもないため、スクリーンショット付きの低評価レビューで初めて知る、という順序になりがちです。回避したいのは破損そのものというより、この気づく順序のほうでした。
同じ 1 行の条件が、コメント削除にも、コンポーネント消失にも、辞書の破壊にもなる。これが一括置換の実際の姿でした。
置換ツールが返す数字は、健全性を何も語りません
ここで整理しておきたいのは、私たちが「成功」と呼んでいるものが何を保証しているかです。
sed の終了コードは、ファイルを読めて書けたかどうかしか示しません。一致件数も同じで、期待した行に当たったことは分かりますが、当たらなかった行がどうなったかは対象外です。git diff も同様で、消えた行は赤く表示されますが、残った行が意味を失ったことは色では表現できません。
つまり、置換の道具はどれも「一致」の側だけを見ています。壊れるのは、ほぼ常に一致しなかった側です。
個人開発でアプリを何本も抱えていると、置換の対象は自然と複数のプロジェクトへまたがります。1 本ずつ開いて目視で確かめられる規模を、置換という道具はひとまたぎで超えてしまう。手が速いことと、確認が追いつくことは別の話でした。
これは AI が出力したコードに対して置換をかけるとき、いっそう効いてきます。生成物は同型の行の反復が多く、命名も規則的です。人間が手で書いたコードなら 3 箇所しか当たらない条件が、生成コードでは 30 箇所に当たる。当たる数が増えるほど、隣接した行が巻き添えになる確率も上がります。生成 → 一括修正 → そのまま提出という流れを取っていると、この巻き添えに気づく機会がそもそも工程の中にありません。
ビルドが緑を返しても保証されない範囲について以前に整理しましたが、今回の破損はビルド以前の問題です。ビルドが通るファイルでも、辞書が欠けていれば画面にキーがそのまま出ます。
置換の「不変条件」を4つ決める
そこで、置換の前後で変わってはいけない性質を先に決めることにしました。差分を目で追うのではなく、性質が保たれているかを機械に問う形です。私が実際に採用したのは次の 4 つです。
- 構造の対称性 — 置換前に中括弧・角括弧・丸括弧・コードフェンスの対応が取れていたファイルは、置換後も取れていること。文字列やコメントの中は数えないよう、先に潰してから数えます。
- パース可能性 — JSON として読めていたファイルは、置換後も読めること。カンマの取り残しはここで確実に捕まります。
- 不変パターンの件数 —
export 宣言や見出しなど、置換の対象ではないはずのものが減っていないこと。件数が減っていたら、それは巻き添えです。
- 削除行の隣接 — 消えた行の直前が、
, や { や => のように「次の行が続く」形で終わっていないか。
4 番目だけ性質が違います。1 から 3 は確定的な破損の証拠ですが、4 番目は疑いにすぎません。ここを同じ扱いにすると使い物にならないことは、後述の実測ではっきり出ました。
ガードスクリプト
依存はありません。置換の前にスナップショットを撮り、置換の後に検査する 2 段構えです。
#!/usr/bin/env node
// replace-guard.mjs — 一括置換の「隣で壊れた行」を検出する
// node replace-guard.mjs snapshot .rg-before src locales 置換前
// node replace-guard.mjs check .rg-before src locales 置換後
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, dirname } from "node:path";
const FENCE = "`".repeat(3);
const PAIRS = [["{", "}"], ["[", "]"], ["(", ")"]];
// 置換で減ってはいけないもの。プロジェクトに合わせて足す
const INVARIANTS = [/\bexport\s+(default|function|const)\b/g, /^\s*##\s+/gm];
const TEXT = /\.(tsx?|jsx?|mjs|json|mdx?|ya?ml)$/i;
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
if (name === "node_modules" || name.startsWith(".")) continue;
const p = join(dir, name);
statSync(p).isDirectory() ? walk(p, out) : TEXT.test(p) && out.push(p);
}
return out;
}
// 文字列・コメント・コードフェンスの中を潰してから括弧を数える
function strip(src) {
return src
.replace(new RegExp(FENCE + "[\\s\\S]*?" + FENCE, "g"), "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "")
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
.replace(/'(?:[^'\\\n]|\\.)*'/g, "''")
.replace(/`(?:[^`\\]|\\.)*`/g, "``");
}
function shape(src) {
const s = strip(src);
const counts = {};
for (const [open, close] of PAIRS) {
counts[open] = (s.split(open).length - 1) - (s.split(close).length - 1);
}
counts[FENCE] = (src.match(new RegExp("^" + FENCE, "gm")) || []).length % 2;
counts.__inv = INVARIANTS.map((re) => (src.match(re) || []).length);
return counts;
}
// 行が「次の行に続く」形で終わっているか
const CONTINUES = /[,{[(+\-*/=&|?:]\s*$|=>\s*$/;
function parseOk(file, src) {
if (/\.json$/i.test(file)) {
try { JSON.parse(src); } catch (e) { return e.message.split("\n")[0]; }
}
return null;
}
const [mode, snapDir, ...roots] = process.argv.slice(2);
const files = roots.flatMap((r) => walk(r));
if (mode === "snapshot") {
for (const f of files) {
const dest = join(snapDir, f);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, readFileSync(f));
}
console.log(`snapshot: ${files.length} files -> ${snapDir}`);
process.exit(0);
}
let failed = 0;
let warned = 0;
for (const f of files) {
const after = readFileSync(f, "utf8");
const snap = join(snapDir, f);
if (!existsSync(snap)) continue;
const before = readFileSync(snap, "utf8");
if (before === after) continue;
const [sb, sa] = [shape(before), shape(after)];
const block = []; // 確定的な破損
const warn = []; // 要確認
for (const k of ["{", "[", "(", FENCE]) {
if (sb[k] === 0 && sa[k] !== 0) block.push(`${k} の対応が崩れました (${sa[k]})`);
}
sb.__inv.forEach((n, i) => {
if (sa.__inv[i] < n) block.push(`不変パターン#${i + 1} が ${n} → ${sa.__inv[i]} に減りました`);
});
const pe = parseOk(f, after);
if (pe && !parseOk(f, before)) block.push(`パースできなくなりました: ${pe}`);
const beforeLines = before.split("\n");
const afterSet = new Set(after.split("\n"));
beforeLines.forEach((line, i) => {
if (afterSet.has(line)) return;
const prev = (beforeLines[i - 1] || "").trimEnd();
if (prev && afterSet.has(beforeLines[i - 1]) && CONTINUES.test(prev)) {
warn.push(`${i}行目を消した結果、直前の行が宙に浮いた可能性: "${prev.slice(-48)}"`);
}
});
if (block.length) {
failed++;
console.log(`\n✗ BLOCK ${relative(process.cwd(), f)}`);
for (const p of [...new Set(block)].slice(0, 4)) console.log(` ${p}`);
} else if (warn.length) {
warned++;
console.log(`\n△ WARN ${relative(process.cwd(), f)}`);
for (const p of [...new Set(warn)].slice(0, 2)) console.log(` ${p}`);
}
}
console.log(`\nBLOCK ${failed} / WARN ${warned}`);
process.exit(failed ? 1 : 0);
先ほどの 4 ファイルに対して実行すると、こうなります。
✗ BLOCK src/LegacyBanner.tsx
{ の対応が崩れました (-1)
不変パターン#1 が 1 → 0 に減りました
△ WARN src/PaywallScreen.tsx
3行目を消した結果、直前の行が宙に浮いた可能性: "llScreen({ onClose }: { onClose: () => void }) {"
△ WARN src/useCredits.ts
5行目を消した結果、直前の行が宙に浮いた可能性: " const spend = (n: number) =>"
✗ BLOCK locales/ja.json
パースできなくなりました: Expected double-quoted property name in JSON at position 58 (line 4 column 1)
BLOCK 2 / WARN 2
sed が 4 回とも 0 を返した同じ操作に対して、確定的な破損 2 件が終了コード 1 で止まります。
偽陽性を確定的な破損と同じ扱いにしてはいけません
ここが設計上いちばん迷った箇所です。
上の実行結果で WARN が 2 件出ていますが、この 2 件はどちらも実際には壊れていません。PaywallScreen.tsx は関数の開き括弧の直後にあったコメントが消えただけ、useCredits.ts は矢印関数の => の直後にあったコメントが消えただけです。どちらも構文としては完全に有効なままです。
つまり、この標本では隣接行ヒューリスティックの精度は 2 件中 0 件でした。単独では役に立ちません。
それでも残した理由があります。useCredits.ts の該当箇所は、こういう形をしていました。
const spend = (n: number) =>
// legacy: clamp until the new quota API ships
setLeft((v) => Math.max(0, v - n));
今回は無事でしたが、もし setLeft の行にも削除条件に当たる語が含まれていれば、矢印関数の本体ごと消えて構文エラーになります。WARN が指しているのは「今回壊れた」ではなく「この形は壊れうる」です。人間が 3 秒見て判断すればよい情報であり、ビルドを止めるべき情報ではありません。
ついでに書いておくと、このスクリプト自体も同じ種類の罠を一度踏んでいます。コードフェンスの数を数えるために、バッククォート 3 つをそのままソースへ書いていました。すると今度は、このスクリプトを記事のコードブロックに載せた時点で、そのリテラルが本文側のフェンスと対応してしまい、以降の段落が丸ごとコードとして解釈されました。文字列の中身を数える処理が、自分の書かれ方によって壊れたわけです。対処は const FENCE に切り出すだけでした。テキストを扱う道具は、自分自身がテキストであることを忘れると足元をすくわれます。
確定的な破損と、構造上の危うさ。この 2 つを同じ出口に流すと、警告が多すぎて誰も読まなくなります。私自身、最初は WARN も終了コードに含めていましたが、通らない置換が続いて数日で外しました。この場合は、機械に止めさせる範囲を狭くしておくほうが結果的に長く使えます。
BLOCK だけを終了コードに反映させ、WARN は標準出力に出すだけ。判断の余地がある情報は、人の目に届けば十分です。
生成コードに置換をかける前の3ステップ
Rork や同種のビルダーで生成したプロジェクトに対しては、次の順序を固定しています。
1. 置換の直前にスナップショットを撮る。 git stash でも git commit でもよいのですが、置換対象の一覧をそのままコピーしておくほうが確実です。生成直後で作業ツリーが汚れている状態でも使えます。
node replace-guard.mjs snapshot .rg-before app components locales
2. 置換する。 ここは今までどおりで構いません。
3. 検査を通してからコミットする。
node replace-guard.mjs check .rg-before app components locales && git add -A
&& で繋ぐのが要点です。BLOCK が 1 件でも出れば git add に到達しません。この 1 行があるだけで、「置換した本人が気づかないまま次の作業へ移る」という経路が塞がれます。本番環境まで運ばれてから発覚する不具合の多くは、こういう塞がれていない経路の先にあります。
不変パターンは、プロジェクトごとに足してください。私は Expo プロジェクトなら export default に加えて、app.config.ts の中の plugins エントリ数と、locales/ 配下の各言語ファイルのキー数を見ています。翻訳キーの件数は言語間で一致しているはずなので、片方だけ減れば置換の巻き添えです。
なお、expo prebuild の既定変更でネイティブディレクトリが再生成される件も、構造としては同じ種類の事故です。手を入れた変更が消えたことに、消えた瞬間には気づけません。スナップショットを撮る習慣は、そちらにもそのまま効きます。
検査を置く場所を、置換より後ろに1つ
今日から試すなら、置換のコマンドを直接叩くのをやめて、上の 3 ステップをシェル関数か npm スクリプトに 1 本まとめてください。それだけで十分です。
私が 975 本のうち 36 本の破損に数週間気づかなかったのは、注意力の問題ではありませんでした。置換が成功したことを確認する手段はあるのに、成果物が健全であることを確認する手段が、工程のどこにも置かれていなかっただけです。道具は「一致した側」しか見ていない。見ていない側を見る係を、自分で 1 つ足す必要がありました。
まだ直しきれていない記事も残っていますが、少なくとも同じ形で増えることはなくなりました。お読みいただきありがとうございました。