From 35ac6b6e745b9e2721cbe3ff7b014dde011e4758 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Wed, 5 Aug 2026 11:15:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=BE=EC=95=84=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20UTF-8=20=EC=95=84=EB=8B=8C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=ED=8C=8C=EA=B4=B4=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readFile 은 방금 고쳤는데 replaceInFiles 에 같은 자리가 그대로 남아 있었다. fs.readFile(abs, "utf8") 로 읽어 치환하고 도로 쓴다. 이쪽이 더 위험하다. 사용자가 열어 보지도 않은 파일을 프로젝트 전체로 훑으며 쓰기 때문이다. 실제 앱에서 재현했다 — CP949 로 된 legacy.c 에서 "int" 를 "long" 으로 한 번 바꿨더니 26바이트가 39바이트가 되고 한글 주석이 전부 U+FFFD 로 바뀌었다. 그러고도 "2개 파일 · 3곳 변경" 성공 토스트가 떴다. UTF-16 파일은 NUL 검사에 걸려 살아남았지만, 살아남았다는 말도 없었다. encoding.detect 로 걸러 건드리지 않고, 건너뛴 파일 목록을 skipped 로 돌려준다. 렌더러는 그 목록을 토스트로 띄운다 — 조용히 빼면 "전부 바꿨다" 로 읽히고, 그 파일들만 옛 이름이 남아 나중에 빌드가 깨진 뒤에야 알게 된다. BOM 붙은 UTF-8 파일은 그대로 치환되고 BOM 도 남는 것을 함께 확인했다. 실제 앱 검증 5/5 (고치기 전 3/5), 단위 954개 통과. --- ide/electron/main.cjs | 15 +++++++++++---- ide/src/App.tsx | 5 +++++ ide/src/i18n/dict/enc.ts | 2 ++ ide/src/schutz.d.ts | 5 +++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index 1ba5284..f445f85 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -833,6 +833,7 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt const inc = globToMatcher(opts?.include); const exc = globToMatcher(opts?.exclude); let changed = 0, files = 0; + const skipped = []; // UTF-8 이 아니라 건드리지 않은 파일 async function walk(dirAbs, relBase, depth) { if (depth > MAX_DEPTH) return; let items; @@ -850,8 +851,14 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt const abs = path.join(dirAbs, it.name); let st; try { st = await fs.stat(abs); } catch { continue; } if (st.size > MAX_FILE_BYTES) continue; - let text; try { text = await fs.readFile(abs, "utf8"); } catch { continue; } - if (text.indexOf(String.fromCharCode(0)) >= 0) continue; + let buf; try { buf = await fs.readFile(abs); } catch { continue; } + // UTF-8 이 아니면 손대지 않는다. 여기가 readFile 보다 위험하다 — 사용자가 열어 + // 보지도 않은 파일을 훑으며 쓰기 때문에, CP949 로 된 파일 하나가 치환 한 번에 + // 조용히 파괴됐다(26바이트 → 39바이트, 한글이 전부 U+FFFD 로). 성공 토스트까지 + // 떴다. 무엇을 건너뛰었는지는 아래에서 돌려준다 — 조용히 빼면 "다 됐다" 로 읽힌다. + const kind = encoding.detect(buf); + if (kind) { skipped.push(rel); continue; } + const text = buf.toString("utf8"); re.lastIndex = 0; const matches = text.match(re); if (!matches || !matches.length) continue; @@ -864,9 +871,9 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt } // walk 가 중간에 죽으면 부분 결과가 남는다 — 성공인 척하지 말고 어디까지 됐는지 알린다 try { await walk(root, "", 0); } catch (e) { - return { changed, files, partial: true, error: e && e.message ? e.message : String(e) }; + return { changed, files, skipped, partial: true, error: e && e.message ? e.message : String(e) }; } - return { changed, files }; + return { changed, files, skipped }; }); // ── 에이전트 명령 실행 ───────────────────────────────────────────────────── diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 87ca412..f1be11a 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -5259,6 +5259,11 @@ ${(r.output || "").slice(0, 2000)}`; if (r.error) { this.toast("error", t("sc3.replaceFailed") + r.error); return; } if (r.partial) this.toast("error", t("sc3.replacePartial", { files: r.files, changed: r.changed })); else this.toast("ok", t("sc3.replaceResult", { files: r.files, changed: r.changed })); + // UTF-8 이 아니라 건너뛴 파일은 반드시 말한다. 안 말하면 "전부 바꿨다" 로 읽히고, + // 그 파일들만 옛 이름이 남아 나중에 빌드가 깨진 뒤에야 알게 된다. + if (r.skipped?.length) { + this.toast("error", t("enc.replaceSkipped", { n: r.skipped.length, files: r.skipped.slice(0, 6).join(", ") })); + } // 모든 non-dirty owned 모델 재로드 — 열린 탭뿐 아니라 preload(닫힌) 모델도 디스크 반영(#8: 나중에 열면 stale 방지). dirty 는 위에서 차단됨. void projectModels.reloadAll(ws.root, (r, rel) => window.schutz!.readFile(r, rel), this.isDirtyRel); this.setState(st => { const pv = { ...st.paneVer }; for (const p of this.allOpen(st)) pv[p] = (pv[p] ?? 0) + 1; return { paneVer: pv }; }); diff --git a/ide/src/i18n/dict/enc.ts b/ide/src/i18n/dict/enc.ts index 5dadbf8..21f3c12 100644 --- a/ide/src/i18n/dict/enc.ts +++ b/ide/src/i18n/dict/enc.ts @@ -5,5 +5,7 @@ export const dict: Record = { "enc.notUtf8": { ko: "이 파일은 UTF-8 이 아닙니다. 열면 글자가 깨지고 저장하면 원본이 사라지므로 열지 않았습니다. UTF-8 로 변환한 뒤 다시 열어 주세요.", en: "This file is not UTF-8. Opening it would garble the text and saving would destroy the original, so it was not opened. Convert it to UTF-8 and try again.", de: "Diese Datei ist nicht UTF-8. Das Öffnen würde den Text zerstören und das Speichern das Original vernichten — sie wurde nicht geöffnet. Bitte in UTF-8 konvertieren.", ja: "このファイルは UTF-8 ではありません。開くと文字化けし、保存すると原本が失われるため開きませんでした。UTF-8 に変換してから開いてください。" }, "enc.utf16": { ko: "이 파일은 UTF-16 입니다. 아직 UTF-8 만 다룰 수 있어 열지 않았습니다 — 열었다면 저장하는 순간 원본이 사라졌을 것입니다.", en: "This file is UTF-16. Only UTF-8 is supported, so it was not opened — saving it would have destroyed the original.", de: "Diese Datei ist UTF-16. Es wird nur UTF-8 unterstützt, daher wurde sie nicht geöffnet — beim Speichern wäre das Original verloren gegangen.", ja: "このファイルは UTF-16 です。現在 UTF-8 のみ対応のため開きませんでした — 保存していれば原本が失われていました。" }, + // 찾아 바꾸기가 건너뛴 파일 — 조용히 빼면 "전부 바꿨다" 로 읽힌다. + "enc.replaceSkipped": { ko: "UTF-8 이 아니라 건드리지 않은 파일 {n}개: {files}", en: "{n} file(s) left untouched because they are not UTF-8: {files}", de: "{n} Datei(en) unberührt gelassen, da nicht UTF-8: {files}", ja: "UTF-8 ではないため触れなかったファイル {n} 件: {files}" }, "enc.binary": { ko: "텍스트 파일이 아닙니다(NUL 바이트가 있습니다). 편집기로 열면 내용이 망가집니다.", en: "This is not a text file (it contains NUL bytes). Opening it in the editor would corrupt it.", de: "Das ist keine Textdatei (sie enthält NUL-Bytes). Im Editor zu öffnen würde sie beschädigen.", ja: "テキストファイルではありません(NUL バイトがあります)。エディターで開くと壊れます。" }, }; diff --git a/ide/src/schutz.d.ts b/ide/src/schutz.d.ts index e7c7b6c..dee527b 100644 --- a/ide/src/schutz.d.ts +++ b/ide/src/schutz.d.ts @@ -214,8 +214,9 @@ interface SchutzApi { Promise<{ ok: boolean; quota?: QuotaInfo; error?: string }>; onQuota(cb: (line: string) => void): () => void; - /** error=정규식 거부 등으로 아무것도 안 함 · partial=도중 실패해 일부만 적용됨 */ - replaceInFiles(root: string, query: string, replacement: string, opts?: any): Promise<{ changed: number; files: number; error?: string; partial?: boolean }>; + /** error=정규식 거부 등으로 아무것도 안 함 · partial=도중 실패해 일부만 적용됨 + * skipped=UTF-8 이 아니라 건드리지 않은 파일(쓰면 원본이 파괴된다) */ + replaceInFiles(root: string, query: string, replacement: string, opts?: any): Promise<{ changed: number; files: number; error?: string; partial?: boolean; skipped?: string[] }>; cliCheck(): Promise<{ agents: Record }>; agentCommands(root: string | null): Promise<{ commands: { name: string; origin: "claude" | "codex"; scope: "user" | "project"; description: string; argHint: string; body: string }[] }>; cliChatCounts(): Promise<{ counts: Record }>;