レビューが通っても安心するな。AI に書かせた CSV 機能が Excel の利用者だけを置き去りにしていた話
対象読者: AI コーディングで管理画面を作っていて、CSV のダウンロード・アップロードを扱う人。
目的: lint・型チェック・AI レビュー・人のレビューを全部通ったコードが、Windows の Excel で開く利用者だけを困らせていた。その原因と、レビューの後に自分で見るべき点をまとめる。
確認環境: Node.js と Chrome(2026 年 10 月時点)。コードは TypeScript だが、ブラウザ標準の API しか使っていない。
今回のレビューは利用環境まで見ていなかった
人事データの CSV 登録・更新・ダウンロード機能を AI と一緒に作り、lint → 型チェック → AI のコードレビュー → 人のレビューを通してマージした。
出荷したダウンロード機能は、Windows の Excel で開くと日本語が化けた。リリース後に不具合として報告され、「メモ帳で開いて UTF-8(BOM 付き)で保存し直してください」と案内する羽目になった。
AI が書いたのはこういうコードで、どこも間違っていない。
const blob = new Blob([csv], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "users.csv";
a.click();
ブラウザでも VS Code でも Mac の Numbers でも開ける。開発者の手元で壊れないのでレビューも通る。壊れるのは利用者の Excel の中であって、コードの中ではない。今回のレビューはコードの正しさまでで、利用環境は確認の対象に入っていなかった。AI は「誰がどの環境で開くか」を指示しないと考慮せず、レビューする側の AI も同じ前提で見る。書く側と見る側が同じ盲点を持つ。
以下は、レビューが通った後に利用者の視点で見直して直した 7 点と、それぞれがなぜレビューで止まらなかったかである。
1. ダウンロードする CSV に BOM が付いているか
今回の Windows・Excel 環境では、BOM(先頭の EF BB BF)が無い UTF-8 の CSV をダブルクリックで直接開くと文字化けした。直接開く以外に、テキスト インポート ウィザードや「データの取得」で取り込む方法もあり(Microsoft サポート「テキスト (.txt または .csv) ファイルのインポートまたはエクスポート」: https://support.microsoft.com/ja-jp/office/5250ac4c-663c-47ce-937b-339e391393ba )、その経路なら文字コードを指定できる。利用者の操作を増やさないために、ダウンロード時に BOM を保証する方針にした。サーバーで BOM を付けていても足りない。ブラウザが UTF-8 のバイト列を文字列にするとき、仕様として先頭の BOM を削るからである(WHATWG Encoding Standard: https://encoding.spec.whatwg.org/#utf-8-decode )。
Node.js で EF BB BF 61 2C 62 を文字列にした結果。今回確認した 3 つの API の既定動作では BOM が消える(TextDecoder に ignoreBOM: true を渡した場合だけ残る)。
Blob.text() : "a,b"
Response.text() : "a,b"
TextDecoder default : "a,b"
対策は「保存する直前に付け直す」。文字列・Blob・URL のどの経路でも同じ関数を通す。
const UTF8_BOM = "";
export function downloadCsv(csv: string, filename: string) {
const body = csv.startsWith(UTF8_BOM) ? csv : UTF8_BOM + csv;
saveBlob(new Blob([body], { type: "text/csv;charset=utf-8" }), filename);
}
レビューで止まらなかった理由: 動作確認は Mac のブラウザで、日本語が表示されたのを「確認済み」とした。Excel で開いた人がいなかった。
2. アップロードされる CSV が Shift_JIS でも読めるか
逆方向も同じ。Windows の Excel で「CSV(コンマ区切り)」として保存すると Shift_JIS になる。FileReader.readAsText() は文字コードを決め打ちするので、バイト列で読み、UTF-8 として厳密に(fatal: true)デコードして失敗したら Shift_JIS に落とす。
function decodeCsvBuffer(buffer: ArrayBuffer): string {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
} catch {
return new TextDecoder("shift_jis").decode(buffer);
}
}
fatal: true が無いと不正なバイトは U+FFFD に置き換えられて例外にならず、Shift_JIS に落ちてこない(MDN: https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/TextDecoder )。
レビューで止まらなかった理由: テストに使った CSV は全部 VS Code で作った UTF-8 だった。
3. = で始まるセルを Excel に数式として扱わせていないか
セルの値が = + - @ やタブ・CR で始まると Excel は数式として評価する。利用者の自由記述をそのまま出力すると =HYPERLINK(...) が実行される(OWASP CSV Injection: https://owasp.org/www-community/attacks/CSV_Injection )。
AI が最初に書いた関数は、= + - @ 始まりに ' を付け、カンマ・改行・" を含むセルだけダブルクォートで囲んでいた。見直して、OWASP の手順に合わせて次の形にした。
- すべてのセルをダブルクォートで囲み、内側の
"は""にする - 先頭の空白を飛ばした後が
=+-@(全角の= + - @も含む)か、タブ・改行・CR で始まる文字列は、先頭に'を付ける - 数値型には
'を付けない
const FORMULA_START = /^\s*[=+\-@=+-@]|^[\t\r\n]/;
function escapeCsvCell(value: string | number): string {
const text =
typeof value === "string" && FORMULA_START.test(value) ? `'${value}` : String(value);
return `"${text.replace(/"/g, '""')}"`;
}
Node.js での変換結果。
"=1+2" -> "'=1+2"
"\n=1+2" -> "'\n=1+2"
"=1+2" -> "'=1+2"
" =1+2" -> "' =1+2"
"-5" -> "'-5" (文字列)
-5 -> "-5" (数値)
"山田,太郎" -> "山田,太郎"
この関数が防ぐのは「ダウンロードした CSV を Excel で開いた時点で数式として評価されること」までである。確認したのは上の変換結果で、Excel 上で数式が評価されないことは今回は検証していない。OWASP は、' 付きの値を Excel で保存し直すと ' が外れてエスケープが無効になる場合があること、Excel 以外のアプリでは ' が見えたまま残ることも挙げている。すべての環境で安全を保証する処理ではなく、自由記述を CSV に出す以上は出力側の無害化と合わせて利用者への注意書きが要る。
レビューで止まらなかった理由: AI のレビューは「エスケープが RFC 4180 に沿っている」と確認して通した。RFC に数式の話は無い。
4. 全角の区切り文字を受け付けているか
複数の値を入れる列は ; 区切りにしたが、日本語入力のまま打つと ; になる。Excel 上では見分けがつかない。半角・全角どちらも受け、前後の空白は落とす。
raw.split(/[;;]/).map((v) => v.trim()).filter(Boolean)
レビューで止まらなかった理由: テストデータは半角で打ってあり、コード上でも Excel 上でも全角と見分けがつかない。
5. 更新する前に「何が変わるか」を見せているか
一括更新は取り込んだ瞬間に数百人分が書き換わる。アップロード直後に現在の値と突き合わせた差分を表で出し、確認してから実行する形にした。差分を出すだけなら AI も書けるが、そのままだと利用者を混乱させる差分が混ざる。
- サーバーが全員に自動で付けるロール(
MEMBER)は比較から外す。外さないと全行が「変更あり」になる - CSV では指定できないが更新後も保持される管理者ロールは「更新後」に足してから比べる。そうしないと「権限が外れる」ように見える
function effectiveAfterRoles(raw: string, user: UserDetail | undefined): string {
const csvRoles = raw === "" ? [] : raw.split(/[;;]/).map((v) => v.trim()).filter((v) => v && v !== "MEMBER");
const retained = (user?.roles ?? []).filter((r) => ["ORG_ADMIN", "SYSTEM_ADMIN"].includes(r));
return [...new Set([...csvRoles, ...retained])].sort().join(";");
}
レビューで止まらなかった理由: 「全員に MEMBER が自動で付く」という仕様はコードのどこにも書いておらず、AI もレビュアーも知らなかった。
6. 上限を超えたとき、黙って切り詰めていないか
1 回の取り込みは 500 行までにした(バックエンドが 1 件ずつ直列に処理し、500 件で約 14 分かかるため)。AI が最初に書いたコードは allRows.slice(1, 501) で、501 行目以降を黙って捨てていた。エラーが出ないので、利用者は全員分が取り込まれたと思う。
上限を超えたら受け付けず、分割を求める文言をファイル選択欄の下に出す。トーストだと数秒で消えて読み切れないので、選び直すまで消えない表示にした。
レビューで止まらなかった理由: slice(1, 501) に「最大 500 データ行」とコメントが付いていて、仕様どおりに見えた。
7. 直した内容を AI に守らせる仕組みがあるか
一番効いたのはコードではなく、ルールの置き場所だった。BOM を 1 画面で直しても、次に AI に別の画面を頼むとまた createObjectURL を書き下ろす。AI は前回の修正を覚えていない。プロジェクトの AI 向け指示ファイルに、理由と禁止事項と確認コマンドを書いた。
## CSV のダウンロードは必ず src/utils/csv.ts の関数を使う
BOM が無い UTF-8 の CSV は、Windows の Excel が Shift_JIS と判定して文字化けする。
Mac・ブラウザでは化けないため気づきにくい。
画面やフックで createObjectURL / new Blob / <a download> を自前で書かない。
- [ ] `git grep -n "createObjectURL" src` で CSV の自前実装が無い
「何をするか」だけでなく「なぜか」を書く。理由が無いと AI は「今回は事情が違う」と例外を作る。
レビューの後に残る仕事
| # | 確認すること | 気づきにくい理由 |
|---|---|---|
| 1 | 保存の直前に BOM を保証しているか | Mac・ブラウザでは化けない |
| 2 | Shift_JIS のアップロードを読めるか | 開発者は UTF-8 しか作らない |
| 3 |
= + - @ 始まりを無害化しているか |
テストデータに含まれない |
| 4 | 全角の区切り・前後の空白を受けるか | 半角と見分けがつかない |
| 5 | 差分表示で自動付与・保持されるロールを除いているか | 仕様がコードに無い |
| 6 | 上限超えを黙って切り詰めていないか | エラーが出ない |
| 7 | 直した理由が AI 向け指示に書いてあるか | 次の画面で再生産される |
この 7 点はどれもレビューの指摘ではなく、リリース後の不具合報告と、Excel で自分のファイルを開いてみた結果から見つかった。lint も AI レビューも人のレビューも、コードが正しいかは見てくれる。見てくれないのは、そのコードが利用者の手元でどう振る舞うかである。通った後に、利用者と同じ環境でひとつ開いてみる。それがレビューでは代わりにならない仕事になる。
