deel-local-cli 1.20.12 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -77,8 +77,165 @@ export function isUtf8(buf) {
77
77
  return true;
78
78
  }
79
79
 
80
+ /*
81
+ * ── 0 바이트가 있다고 다 그림은 아니다 ──────────────────────────────────
82
+ *
83
+ * UTF-16 은 영문 한 글자를 두 바이트로 쓴다. 뒤 한 바이트는 0 이다.
84
+ *
85
+ * 'Hello' → 48 00 65 00 6C 00 6C 00 6F 00
86
+ *
87
+ * 그래서 「0 바이트가 있으면 그림」 이라는 잣대에 **멀쩡한 글 파일이 통째로
88
+ * 걸렸다.** 읽기 도구가 그 잣대를 먼저 보므로, 사람은 이런 답을 받았다 —
89
+ *
90
+ * Read notes.txt
91
+ * ✗ 바이너리 파일입니다 — 텍스트로 읽을 수 없습니다
92
+ *
93
+ * 앞머리에 표식(BOM)이 **찍혀 있어도** 그랬다. 이 파일은 utf-16le 를 알아보고
94
+ * 읽고 바이트까지 똑같이 되돌려 쓸 줄 아는데, 그 앞에서 막혔다. 윈도우에서
95
+ * 만든 파일에 흔한 인코딩이라 남의 파일이 아니라 제 파일에서 걸린다.
96
+ *
97
+ * ── 자리 쏠림만으로는 한글을 하나도 못 건졌다 ──────────────────────────
98
+ *
99
+ * 처음 고칠 때 잣대를 「0 이 **한쪽 자리에만** 나온다」 로 잡았다 — LE 는
100
+ * 홀수 자리, BE 는 짝수 자리. 영문에는 맞는 말이고, 그래서 영문 UTF-16 은
101
+ * 그때 읽히기 시작했다. 그리고 머리말에 「한글 UTF-16 문서도 읽는다」 고
102
+ * 적었다. **재 보지 않고 적었다.** 재 보니 한 줄도 안 읽혔다:
103
+ *
104
+ * UTF-16LE "안녕하세요 반갑습니다" ✗ 바이너리 파일입니다
105
+ * UTF-16LE "글을 쓰는 자리입니다" ✗ 바이너리 파일입니다
106
+ * UTF-16BE 같은 글 ✗ 바이너리 파일입니다
107
+ *
108
+ * 한글은 두 바이트가 다 차 있어서(`48 C5`) 0 이 안 나온다. 그러니 0 이
109
+ * 나오는 자리는 **띄어쓰기와 줄바꿈뿐**이고, 그건 홀수 자리에 한둘이다 —
110
+ * 「둘 이상」 이라는 문턱에 걸린다. 게다가 `가`(U+AC00) · `글`(U+AE00)처럼
111
+ * 낮은 바이트가 0 인 음절이 하나만 섞이면 **짝수 자리에도** 0 이 생겨서
112
+ * 「한쪽에만」 이 통째로 깨진다. 그 두 가지가 겹치면 BE 쪽 조건에 걸려
113
+ * **거꾸로 읽히기까지** 한다.
114
+ *
115
+ * ── 그래서 이미 있는 점수표에 후보로 넣는다 ────────────────────────────
116
+ *
117
+ * 아래 guess() 는 후보마다 엄격하게 풀어 보고 「그 인코딩으로 쓴 진짜 글
118
+ * 같은가」 를 점수로 매긴다. 표본 21개를 21개 맞힌 자다. UTF-16 도 그
119
+ * 저울에 같이 올리면 될 일이었다.
120
+ *
121
+ * 그냥 풀어 보는 것만으로는 **못 가른다.** 재 봤다 — CP949 로 쓴 한국어
122
+ * 문서도, Shift_JIS 일본어 문서도, GBK 중국어 문서도 UTF-16BE 로 깨끗하게
123
+ * 풀린다(제어문자 하나 없이). 나오는 글자가 희귀한 한글·한자일 뿐이다.
124
+ * 가르는 것은 **흔한 글자냐**이고, 그건 점수표가 이미 세고 있다.
125
+ *
126
+ * 자리 쏠림은 버리지 않고 **증거 한 가지**로 남긴다. 0 이 넉넉히 나오는
127
+ * 영문 UTF-16 은 쏠림이 결정적이라, 거기에만 힘을 준다.
128
+ */
129
+ function 글같나(buf, id, 잘림 = false) {
130
+ let text;
131
+ // 잘라 낸 표본이면 끝 글자가 반쪽일 수 있다 — stream 으로 풀면 그 반쪽은 탈이 아니다.
132
+ try { text = new TextDecoder(id, { fatal: true }).decode(buf, { stream: 잘림 }); } catch { return null; }
133
+ if (!text.length) return null;
134
+ for (const ch of text) {
135
+ const c = ch.codePointAt(0);
136
+ if (c === 9 || c === 10 || c === 13) continue;
137
+ if (c < 0x20 || (c >= 0x7F && c <= 0x9F)) return null; // 제어문자 — 글이 아니다
138
+ if (c >= 0xE000 && c <= 0xF8FF) return null; // 사용자 영역 — 글에 안 나온다
139
+ }
140
+ return text;
141
+ }
142
+
143
+ /**
144
+ * UTF-16 으로 풀리는 후보들. 점수는 아래 점수() 와 같은 저울이다.
145
+ *
146
+ * 두 바이트 단위라 **다 온 파일**의 길이가 홀수면 애초에 아니다. 앞머리만
147
+ * 잘라 온 표본(`잘림`)은 다르다 — 글자 한가운데서 끊긴 것이라 홀수가 정상이다.
148
+ * 그 둘을 안 가르고 홀수 바이트를 그냥 버리던 때는, 다 온 홀수 길이 파일이
149
+ * UTF-16 으로 읽혔다 (9회차 · 2차 눈 · 주석과 코드가 서로 다른 말).
150
+ *
151
+ * 너무 짧으면 우연히 풀리는 것이 많다. 두 자(넉 바이트)가 안 되면 볼 것이
152
+ * 아예 없어 그만두고, 그 위로는 점수만으로 정하지 않고 **자리 쏠림**을 같이 본다
153
+ * (아래 쏠림). 「여덟 바이트는 있어야 본다」 고 적혀 있던 자리다 — 코드는 그런
154
+ * 문턱을 둔 적이 없다. 문턱이 아니라 쏠림이 가르는 것이라 코드 쪽이 맞았다.
155
+ *
156
+ * @param {boolean} 잘림 앞머리만 잘라 온 표본인가
157
+ */
158
+ function utf16후보(buf, 잘림 = false) {
159
+ if (!잘림 && buf.length % 2) return [];
160
+ const n = Math.min(buf.length - (buf.length % 2), 8000);
161
+ if (n < 4) return [];
162
+ const 본것 = buf.subarray(0, n);
163
+ let 짝 = 0;
164
+ let 홀 = 0;
165
+ for (let i = 0; i < n; i++) if (본것[i] === 0) { if (i % 2) 홀 += 1; else 짝 += 1; }
166
+ // 0 이 한쪽에 몰려 있고 그 수가 넉넉하면(글자 넷 중 하나꼴) 쏠림이 결정적이다.
167
+ const 넉넉 = n / 8;
168
+ const out = [];
169
+ for (const id of ['utf-16le', 'utf-16be']) {
170
+ /*
171
+ * 앞 8000바이트만 볼 때는 그 자리가 짝(서로게이트) 한가운데일 수 있다.
172
+ * 부르는 쪽이 「앞머리만 잘라 왔다」 고 한 때도 마찬가지다 — 그 말을
173
+ * 여기까지 안 옮기던 때는, 짝수 길이로 잘린 표본의 마지막 서러게이트가
174
+ * 반쪽만 남아 fatal 디코더가 던졌고 **그 결과 UTF-16LE 짜리가 UTF-16BE
175
+ * 로 읽혔다.** 못 알아본 것이 아니라 **다른 것으로 알아봤다.**
176
+ */
177
+ const 글 = 글같나(본것, id, 잘림 || n < buf.length);
178
+ if (글 === null) continue;
179
+ const 맞는쪽 = id === 'utf-16le' ? 홀 : 짝;
180
+ const 틀린쪽 = id === 'utf-16le' ? 짝 : 홀;
181
+ const 쏠림 = 틀린쪽 === 0 && 맞는쪽 >= 넉넉 && 맞는쪽 >= 2;
182
+ out.push({ id, score: 점수(글, id) + (쏠림 ? 3 : 0), 쏠림 });
183
+ }
184
+ out.sort((a, b) => b.score - a.score);
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * 이 바이트들은 UTF-16 인가. 아니면 null.
190
+ *
191
+ * @param {Buffer} buf
192
+ * @param {Array} 옛것후보 이미 매겨 둔 레거시 후보(guess 의 결과). 없으면 여기서 매긴다.
193
+ */
194
+ export function utf16인가(buf, 옛것후보 = null, { 잘림 = false } = {}) {
195
+ const bom = bomOf(buf);
196
+ if (bom && bom.id !== 'utf-8') return bom.id;
197
+ const 열여섯 = utf16후보(buf, 잘림);
198
+ if (!열여섯.length) return null;
199
+ /*
200
+ * ── UTF-8 로도 말이 되면 **자리 쏠림이 있어야** UTF-16 이라 한다 ────────
201
+ *
202
+ * 아스키만 든 글은 어떤 두 바이트를 묶어도 한자 한 글자가 된다. 그래서
203
+ * 점수만 보면 UTF-16 이 이긴다 — 옛 인코딩으로는 그냥 아스키라 셀 글자가
204
+ * 없어 0점이고, UTF-16 으로는 한자가 쏟아져 3점씩 붙기 때문이다.
205
+ *
206
+ * "hello-encoding" → 桥汬漭敮捯摩湧
207
+ * "hello\0world … text ok" → 敨汬o 潷汲… (NUL 한 개 든 평범한 글)
208
+ * 01 00 03 00 05 00 … → ĀȀ̀… (한쪽에만 0 인 제어문자 덩이)
209
+ *
210
+ * 앞 둘은 멀쩡한 UTF-8 이고 셋째는 그림이다. 셋 다 UTF-16 으로 읽혀서
211
+ * 「NUL 있으면 바이너리」 와 「명령 출력을 바이트로 받아 푼다」 가 같이
212
+ * 빨개졌다.
213
+ *
214
+ * 가르는 것은 **자리 쏠림**이다. 진짜 UTF-16 아스키 문서는 글자마다 0 이
215
+ * 하나씩 같은 자리에 박힌다 — 넉넉하고 가지런하다. 위 셋은 0 이 없거나
216
+ * (앞 둘) 가지런해도 수가 안 맞는다.
217
+ *
218
+ * 한글·한자가 든 UTF-16 은 이 조건에 안 걸린다. 그 바이트열은 UTF-8
219
+ * 규칙에 안 맞아서 여기 오지도 않는다.
220
+ */
221
+ if (isUtf8(buf) && !열여섯[0].쏠림) return null;
222
+ /*
223
+ * 옛 인코딩으로 읽는 편이 더 그럴듯하면 UTF-16 이 아니다.
224
+ *
225
+ * 이 한 줄이 거짓 양성을 막는다. CP949 문서는 euc-kr 로 풀면 흔한 한글이
226
+ * 쏟아지고(높은 점수) UTF-16BE 로 풀면 희귀한 한글이 나온다(낮은 점수).
227
+ * 반대로 진짜 UTF-16 한글 문서는 옛 인코딩으로 풀면 0 바이트가 제어문자로
228
+ * 잡혀 크게 깎인다.
229
+ */
230
+ const 옛것 = 옛것후보 ?? guess(buf, { 잘림 });
231
+ const 제일나은옛것 = 옛것.length ? 옛것[0].score : -Infinity;
232
+ if (열여섯[0].score <= 제일나은옛것) return null;
233
+ return 열여섯[0].id;
234
+ }
235
+
80
236
  /** 글자가 아닌 파일인가. 0 바이트가 있으면 그림·실행파일 같은 것이다. */
81
237
  export function looksBinary(buf) {
238
+ if (utf16인가(buf)) return false;
82
239
  const n = Math.min(buf.length, 8000);
83
240
  for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
84
241
  return false;
@@ -116,6 +273,15 @@ const 기대 = {
116
273
  'gbk': { 한글: -3, 자모: -2, 가나: -1, 한자: 3, 라틴: -3, 부호: 1 },
117
274
  'big5': { 한글: -3, 자모: -2, 가나: -1, 한자: 3, 라틴: -3, 부호: 1 },
118
275
  'windows-1252': { 한글: 0, 자모: 0, 가나: 0, 한자: 0, 라틴: 3, 부호: 0 },
276
+ /*
277
+ * UTF-16 은 어느 나라 말이든 담는다. 그래서 「이 인코딩이면 이 글자」 라는
278
+ * 편향이 없다 — 한글·가나·한자·라틴을 다 곧이곧대로 받는다.
279
+ *
280
+ * 자모만 음수다. 낱자(ㄱ·ㅏ)만 늘어놓은 글은 사람이 안 쓴다. 다른 인코딩을
281
+ * UTF-16 으로 잘못 풀었을 때 자주 나오는 것이 그것이라, 여기서 가른다.
282
+ */
283
+ 'utf-16le': { 한글: 4, 자모: -2, 가나: 4, 한자: 3, 라틴: 3, 부호: 1 },
284
+ 'utf-16be': { 한글: 4, 자모: -2, 가나: 4, 한자: 3, 라틴: 3, 부호: 1 },
119
285
  };
120
286
 
121
287
  // 자주 쓰는 글자. 동점을 가르는 것은 결국 이것이다.
@@ -123,25 +289,108 @@ const 기대 = {
123
289
  const 흔한한글 = new Set([...'이다는에하고지의있을로가사서대시한를수요리어아스나자기인정부상도문그무전등성니습해개년월일시분초원건확인요청결재보고회의첨부']);
124
290
  const 흔한한자 = new Set([...'的一是不了人我在有他这中大来上国个到说们为子和你地出道也时年得就那要下以生会自着去之过家学对可里后小么心多天而能好都然没日于起还发成事只作当想看文无开手十用主行方又如前所本见经头面公同三已老从动两长知民样进最新報告書項目度務部社長株式會員請查收謝件附這測試繁體簡']);
125
291
 
126
- /** 후보를 점수순으로. 첫 번째가 가장 그럴듯한 것이다. */
127
- export function guess(buf) {
292
+ /**
293
+ * 후보를 점수순으로. 첫 번째가 가장 그럴듯한 것이다.
294
+ *
295
+ * `잘림` 은 **파일 앞머리만 잘라 온 표본**이라는 뜻이다.
296
+ *
297
+ * 큰 파일은 앞 64KB 만 보고 판정한다(tools/index.js 의 재는인코딩). 그런데
298
+ * 그 자리가 두 바이트 글자의 한가운데면 앞 바이트 하나가 외톨이로 남고,
299
+ * 엄격 모드는 그 한 바이트 때문에 CP949 후보를 **통째로** 떨어뜨렸다. 남는
300
+ * 것은 아무 바이트나 받는 CP1252 뿐이라 64KB 넘는 사내 CP949 로그에 한 줄
301
+ * 붙이면 「CP1252 에 없는 글자」 로 거절되거나, `·` 처럼 양쪽에 다 있는
302
+ * 글자는 CP1252 바이트로 **조용히** 붙었다. 자른 자리를 맞추던 자는 UTF-8
303
+ * 경계만 알았다 — 오히려 한 바이트를 더 깎아 외톨이를 만들기도 했다.
304
+ *
305
+ * stream 으로 풀면 끝의 반쪽 글자는 「아직 덜 온 것」 이라 탈이 아니다.
306
+ * 한가운데의 없는 조합은 여전히 탈이라 거르는 힘은 그대로다.
307
+ */
308
+ export function guess(buf, { 잘림 = false } = {}) {
128
309
  const 후보 = [];
129
310
  for (const cand of LEGACY) {
130
311
  let text;
131
312
  // 엄격 모드로 해독한다. 없는 조합이 하나라도 있으면 그 인코딩이 아니다.
132
- // windows-1252 도 정의 안 된 바이트가 다섯 개 있어서 여기서 걸러진다.
133
- try { text = new TextDecoder(cand.id, { fatal: true }).decode(buf); } catch { continue; }
313
+ //
314
+ // 다만 **windows-1252 는 여기서 안 걸러진다.** WHATWG 의 windows-1252 는 정의가
315
+ // 비어 있는 다섯 바이트(81·8D·8F·90·9D)도 같은 번호의 제어문자로 옮기게 돼 있어서,
316
+ // fatal 로 돌려도 256 바이트를 다 받는다(검사: 인코딩 「늘 후보다」). 그래서
317
+ // windows-1252 는 언제나 후보로 남고, 가르는 일은 전부 점수가 한다.
318
+ // 걸러지는 것은 두 바이트 인코딩 넷뿐이다.
319
+ try { text = new TextDecoder(cand.id, { fatal: true }).decode(buf, { stream: 잘림 }); } catch { continue; }
134
320
  후보.push({ id: cand.id, score: 점수(text, cand.id) });
135
321
  }
136
322
  후보.sort((a, b) => b.score - a.score);
137
323
  return 후보;
138
324
  }
139
325
 
326
+ /*
327
+ * ── 짧은 파일에서 판정이 흔들리던 세 가지 ───────────────────────────────
328
+ *
329
+ * 실제 문장을 N 글자씩 잘라 재 봤다(표본 문장 넷 × 자리 여럿). 고치기 전:
330
+ *
331
+ * Shift_JIS 「ます。担当は山田太郎です。東京本」 (20자) → GBK
332
+ * GBK 「并提交报告,谢谢大家的配合与支持。负责人是张经理」 (24자, 32자도) → UTF-16BE
333
+ * 짧은 표본 모음(두세 글자 낱말 183개) → 29개 틀림
334
+ *
335
+ * 스무 자가 넘는 사내 문서가 엉뚱한 인코딩으로 읽히면 「짧아서」 가 아니다.
336
+ * 까닭은 둘이었다.
337
+ *
338
+ * 1) GBK · Big5 는 거의 모든 두 바이트를 받는다. Shift_JIS 한자 바이트
339
+ * (앞 0x88–0x9F)를 GBK 로 풀면 **GB2312 밖 확장 자리**의 한자가 줄줄이
340
+ * 나온다. 중국어 글은 그 자리 글자를 드물게 쓰는데 점수표는 같은 3점을 줬다.
341
+ * → 확장 자리(GBK: 앞 0xB0–0xF7·뒤 0xA1 이상 밖, Big5: 앞 0xA4–0xF9 밖)
342
+ * 한자는 1점으로 센다. 넉 자 미만이면 안 깎는다 — 석 자로는 드문지
343
+ * 흔한지 말할 근거가 없고, 그때는 힌트가 가른다(박빙).
344
+ * 2) UTF-16 으로 잘못 풀면 두 바이트마다 아무 한글·한자가 나온다. 진짜
345
+ * 한국어·중국어 글에는 흔한 글자가 섞이는데 점수표는 드문 음절에도
346
+ * 4점을 줬다. → UTF-16 후보에서 흔한 글자 목록 밖의 한글 음절은 2점,
347
+ * 한자는 1점. (옛 인코딩 후보는 그대로다 — 거기서는 이미 비대칭이 가른다)
348
+ *
349
+ * 고친 뒤(같은 표본): 넉 자부터 Shift_JIS · Big5 · CP949 는 한 개도 안 틀리고,
350
+ * **여섯 자부터 옛 인코딩 넷이 다 맞는다.** 표식 없는 UTF-16 은 **열 자부터**
351
+ * 다 맞는다. 짧은 표본 모음은 29 → 18.
352
+ *
353
+ * ── 그보다 짧으면 **못 가른다** — 짐작으로 다룬다 ─────────────────────────
354
+ *
355
+ * 두세 글자는 같은 바이트가 여러 인코딩에서 멀쩡한 글이다. `谢谢`(GBK D0BB D0BB)
356
+ * 는 UTF-8 로도 맞는 `лл` 이고, `確認` 은 Shift_JIS 로도 GBK 로도 흔한 한자다.
357
+ * 점수를 더 비틀면 반대쪽(짧은 UTF-16, 짧은 GBK)이 틀린다 — 재 봤다. 그래서
358
+ * 여섯 자(UTF-16 은 열 자) 미만인 옛 인코딩 조각은 대개 sure:false 로 돌아오고, Read 는
359
+ * 「짐작」 으로 적고, Write·Edit 은 그 표시를 달고 쓴다. 힌트(fallback)가 박빙을 가른다.
360
+ *
361
+ * **예외 하나** — 바이트가 UTF-8 로도 빈틈없이 맞으면 UTF-8 이 먼저 이기고 sure:true 다.
362
+ * 위의 `谢谢` 가 그렇다: utf-8 · sure:true 로 `лл` 이 나온다. 짧은 조각에서
363
+ * 「sure:true 면 틀림없다」 로 읽으면 안 된다 — 이 크기에서는 알려진 한계다.
364
+ */
140
365
  function 점수(s, id) {
141
366
  const w = 기대[id];
367
+ const 열여섯 = id.startsWith('utf-16');
142
368
  let 합 = 0;
143
369
  let 수 = 0;
370
+ let 드문 = 0;
144
371
  const cs = [...s];
372
+ /*
373
+ * ── 한글이 멀쩡하면 CP949 의 한자는 깎지 않는다 (2.0.0 6회차 · Gemini 인코딩6) ──
374
+ *
375
+ * 「CP949 문서에 한자는 드물다」 로 한자마다 깎았다. 그런데 계약서·공문은 한자를 섞어 쓴다 —
376
+ * 「契約書 第1條 株式會社 甲과 乙은 …」 이 GBK 로 읽혀 글이 통째로 중국 글자로 깨졌다. 한글
377
+ * 음절 바이트를 GBK 로 풀면 흔한 중국 한자가 나와서 GBK 가 0.4–0.8점 앞섰다.
378
+ *
379
+ * 가르는 것은 한글 쪽이다. 진짜 한국어는 흔한 음절(이·다·는·을…)이 넉넉히 섞이고, 중국어·
380
+ * 일본어 바이트를 CP949 로 잘못 풀면 2,350 음절 중 아무것이나 나와 흔한 것은 드물다(수 %).
381
+ * 그래서 흔한 음절이 셋 이상이고 한글의 30% 이상이면 한자를 한국 글의 한자로 쳐 준다.
382
+ */
383
+ let 한자값 = w.한자;
384
+ if (id === 'euc-kr') {
385
+ let 한글수 = 0;
386
+ let 흔한수 = 0;
387
+ for (const ch of cs) {
388
+ if (!한글음절(ch.codePointAt(0))) continue;
389
+ 한글수++;
390
+ if (흔한한글.has(ch)) 흔한수++;
391
+ }
392
+ if (흔한수 >= 3 && 흔한수 >= 한글수 * 0.3) 한자값 = 2;
393
+ }
145
394
  for (let i = 0; i < cs.length; i++) {
146
395
  const ch = cs[i];
147
396
  const c = ch.codePointAt(0);
@@ -151,10 +400,14 @@ function 점수(s, id) {
151
400
  합 -= 8; 수++; continue;
152
401
  }
153
402
  수++;
154
- if (한글음절(c)) 합 += w.한글;
403
+ // UTF-16 으로 풀린 드문 한글·한자는 덜 믿는다 (위 머리말 2).
404
+ if (한글음절(c)) 합 += (열여섯 && !흔한한글.has(ch)) ? 2 : w.한글;
155
405
  else if (한글자모(c)) 합 += w.자모;
156
406
  else if (가나(c)) 합 += w.가나;
157
- else if (한자(c)) 합 += w.한자;
407
+ else if (한자(c)) {
408
+ 합 += (열여섯 && !흔한한자.has(ch)) ? 1 : 한자값;
409
+ if (확장자리한자(id, ch)) 드문 += 1;
410
+ }
158
411
  else if (라틴(c)) 합 += w.라틴;
159
412
  else if (동아부호(c)) 합 += w.부호;
160
413
  else if (c >= 0xE000 && c <= 0xF8FF) 합 -= 8; // 사용자 영역 — 글에 나올 리 없다
@@ -171,9 +424,21 @@ function 점수(s, id) {
171
424
  if ((앞 !== undefined && 로마자(앞)) || (뒤 !== undefined && 로마자(뒤))) 합 -= 2;
172
425
  }
173
426
  }
427
+ // GBK·Big5 확장 자리 한자는 1점으로 (위 머리말 1). 넉 자 미만은 힌트에 맡긴다.
428
+ if (수 >= 4) 합 -= 드문 * (w.한자 - 1);
174
429
  return 수 ? 합 / 수 : 0;
175
430
  }
176
431
 
432
+ /** GBK·Big5 로 쓴 이 한자가 흔한 글에 드문 **확장 자리**에 있나. 다른 인코딩이면 거짓. */
433
+ function 확장자리한자(id, ch) {
434
+ if (id !== 'gbk' && id !== 'big5') return false;
435
+ const b = reverseTable(id).get(ch);
436
+ if (!b || b.length !== 2) return true;
437
+ // GBK: GB2312 한자 자리(앞 B0–F7, 뒤 A1–FE). Big5: 상용·차상용 한자(앞 A4–F9).
438
+ if (id === 'gbk') return !(b[0] >= 0xB0 && b[0] <= 0xF7 && b[1] >= 0xA1);
439
+ return !(b[0] >= 0xA4 && b[0] <= 0xF9);
440
+ }
441
+
177
442
  // 짐작이 이만큼 안에서 갈리면 사실상 동점이다. 그때는 힌트를 따른다.
178
443
  const 박빙 = 0.5;
179
444
 
@@ -191,30 +456,127 @@ const 박빙 = 0.5;
191
456
  * fallback 은 명령이 아니라 힌트다. 내용이 분명하면 내용이 이긴다.
192
457
  * 짐작이 박빙일 때만 힌트가 결정을 한다 — 짧은 파일은 근거가 모자라기 때문이다.
193
458
  */
194
- export function detect(buf, { fallback = null, system = null } = {}) {
459
+ /*
460
+ * 힌트 이름을 받아 준다.
461
+ *
462
+ * 아는 이름은 `euc-kr` 처럼 TextDecoder 가 쓰는 것뿐이었다. 그런데 사람도
463
+ * 설정도 코드페이지 이름으로 적는다 — 화면에 `CP949` 라고 찍어 주는 것이
464
+ * 이 파일의 label() 이다. 그렇게 적어 넣으면 `후보.find(x => x.id === 힌트)`
465
+ * 가 영영 못 찾아서 **힌트가 통째로 무시됐다.** 무시했다는 말도 안 나온다.
466
+ *
467
+ * 짐작이 박빙일 때만 힌트가 결정을 하니, 안 듣는 것을 알아채기도 어렵다 —
468
+ * 대개는 내용이 이겨서 맞는 답이 나오고, 아슬아슬한 파일에서만 틀린다.
469
+ */
470
+ export function 이름정리(값) {
471
+ const 날것 = String(값 ?? '').trim().toLowerCase();
472
+ if (!날것) return null;
473
+ /*
474
+ * 유니코드 이름은 **그대로 돌려준다.**
475
+ *
476
+ * 여기는 옛 인코딩 이름만 알았고, 모르면 null 이었다. 그래서 `fallback:
477
+ * 'utf-8'` 을 넣으면 `이름정리(fallback) ?? 이름정리(system) ?? …` 에서
478
+ * null 로 떨어져 **이 컴퓨터의 옛 인코딩**이 대신 힌트가 됐다. 사람이
479
+ * 「모르겠으면 UTF-8 로 봐」 라고 적어 둔 자리에서 CP949 를 밀어 준 셈이다.
480
+ *
481
+ * 힌트 후보에는 안 걸린다(옛 인코딩만 후보다). 그게 맞는 답이다 — 여기까지
482
+ * 왔다는 것은 UTF-8 이 이미 아니라고 판정됐다는 뜻이라, 그 힌트로 밀어
483
+ * 줄 것이 없다. 다만 **사람이 적은 것을 남의 것으로 바꿔치지는 않는다.**
484
+ */
485
+ if (/^utf-?8(-bom)?$/.test(날것)) return 'utf-8';
486
+ if (/^utf-?16-?le(-bom)?$/.test(날것)) return 'utf-16le';
487
+ if (/^utf-?16-?be(-bom)?$/.test(날것)) return 'utf-16be';
488
+ /*
489
+ * ── 구분자를 떼고 본다 (2.0.0 7회차 · Gemini 인코딩7a) ────────────────────
490
+ *
491
+ * 위 고침은 **앞가지**(cp·ms·windows-)만 걷었다. 구분자는 그대로 남아서
492
+ * `shift-jis` 한 줄이 여전히 null 이었다 — 웹 charset 도 메일 머리글도
493
+ * 하이픈으로 적는 쪽이 더 흔하고, `iso-8859-1` 은 웹에서 제일 많이 적히는
494
+ * 이름인데 여기는 한 번도 알아들은 적이 없다.
495
+ *
496
+ * 무시하면 아무 말도 안 남는다는 것이 이 자리의 값어치다. 그래서 사람이
497
+ * 쓰는 꼴을 **다 적어 놓고** 고른다. 짐작으로 받아 주지는 않는다 — 모르는
498
+ * 이름을 아무거나로 읽으면 힌트가 거짓말이 된다.
499
+ */
500
+ const 납작 = 날것.replace(/[\s._-]+/g, '').replace(/^x/, '');
501
+ const 별명 = {
502
+ // 한국
503
+ euckr: 'euc-kr', ksc5601: 'euc-kr', ksc56011987: 'euc-kr', kscms5601: 'euc-kr',
504
+ uhc: 'euc-kr', cp949: 'euc-kr', ms949: 'euc-kr', windows949: 'euc-kr', 949: 'euc-kr',
505
+ // 일본
506
+ shiftjis: 'shift_jis', sjis: 'shift_jis', mskanji: 'shift_jis', shiftjis2004: 'shift_jis',
507
+ cp932: 'shift_jis', ms932: 'shift_jis', windows932: 'shift_jis', 932: 'shift_jis',
508
+ // 중국
509
+ gbk: 'gbk', gb2312: 'gbk', csgb2312: 'gbk', chinese: 'gbk',
510
+ cp936: 'gbk', ms936: 'gbk', windows936: 'gbk', 936: 'gbk',
511
+ // 대만
512
+ big5: 'big5', csbig5: 'big5', cp950: 'big5', ms950: 'big5', windows950: 'big5', 950: 'big5',
513
+ // 서유럽 — WHATWG 는 iso-8859-1 을 windows-1252 로 읽으라고 못 박았다.
514
+ windows1252: 'windows-1252', cp1252: 'windows-1252', ms1252: 'windows-1252', 1252: 'windows-1252',
515
+ latin1: 'windows-1252', l1: 'windows-1252', iso88591: 'windows-1252', iso885915: 'windows-1252',
516
+ };
517
+ return 별명[납작] ?? null;
518
+ }
519
+
520
+ export function detect(buf, { fallback = null, system = null, 잘림 = false } = {}) {
195
521
  if (!buf.length) return { id: 'utf-8', sure: true, why: '빈 파일' };
196
522
 
197
523
  const bom = bomOf(buf);
198
524
  if (bom) return { id: bom.id, sure: true, bom: bom.size, why: '앞머리 표식' };
199
525
 
526
+ /*
527
+ * 표식 없는 UTF-16 을 여기서 가른다. isUtf8 보다 **먼저** 봐야 한다.
528
+ *
529
+ * UTF-16 로 쓴 영문은 바이트가 전부 0x7F 아래라서 isUtf8 이 그냥 통과시킨다.
530
+ * 그러면 「UTF-8 · 확실함」 이라고 답하고, 글은 `H\0e\0l\0l\0o\0` 가 된다 —
531
+ * 글자 수가 두 배가 되고 사이사이 0 이 박힌다. 확실하다고 적어 놓은 답이라
532
+ * 부르는 쪽이 되물을 까닭도 없다.
533
+ */
534
+ const u16 = utf16인가(buf, null, { 잘림 });
535
+ if (u16) return { id: u16, sure: true, bom: 0, why: '두 바이트마다 0 — 표식 없는 UTF-16' };
536
+
200
537
  if (isUtf8(buf)) {
201
- const 한글밖 = buf.some((b) => b > 0x7F);
202
- return { id: 'utf-8', sure: true, why: 한글밖 ? 'UTF-8 규칙에 맞음' : 'ASCII 뿐' };
538
+ const 아스키밖 = buf.some((b) => b > 0x7F);
539
+ return { id: 'utf-8', sure: true, why: 아스키밖 ? 'UTF-8 규칙에 맞음' : 'ASCII 뿐' };
203
540
  }
204
541
 
205
- const 힌트 = fallback ?? system ?? systemLegacy();
206
- const 후보 = guess(buf);
542
+ // 사람이 적어 준 힌트와 이 컴퓨터의 기본값을 갈라 둔다. 못 썼다고 말해 주는 것은
543
+ // **적어 준 것**뿐이다 — 아무도 안 적은 기본값을 못 썼다고 탓하면 그건 군말이다.
544
+ const 적힌힌트 = 이름정리(fallback) ?? 이름정리(system);
545
+ const 힌트 = 적힌힌트 ?? systemLegacy();
546
+ const 후보 = guess(buf, { 잘림 });
547
+ /*
548
+ * 지금 목록으로는 여기 못 온다 — WHATWG 의 windows-1252 는 256 바이트를 다 받아서
549
+ * 엄격 모드로도 안 걸러진다(검사: 인코딩 「늘 후보다」). LEGACY 목록이 바뀌는 날을
550
+ * 위한 그물로만 남긴다. **여기 올 수 있는 척 적어 두면 아래 힌트 이야기가 거짓말이 된다.**
551
+ */
207
552
  if (!후보.length) {
208
553
  return { id: 힌트, sure: false, why: '어느 인코딩으로도 말이 안 됨 — 힌트로 봄' };
209
554
  }
210
555
 
556
+ /*
557
+ * 적어 준 힌트로 **아예 안 읽히는** 바이트면 후보에 못 오른다. 그러면 박빙 비교에도
558
+ * 안 들어가고 화면에는 「내용으로 짐작」 한 줄만 남는다 — 사람이 적어 준 것을 안 썼는데
559
+ * 안 썼다는 말이 없다.
560
+ *
561
+ * 이름정리 머리말이 같은 꼴을 이미 한 번 걷어냈다(이름을 못 알아들어 힌트가 통째로
562
+ * 무시되던 자리). 여기는 이름이 아니라 **바이트**가 안 맞는 쪽이라 그 고침이 안 닿았다.
563
+ * 한 파일씩 볼 때는 답이 맞아서 안 보이고, 여러 파일을 한꺼번에 돌릴 때 어느 파일에서
564
+ * 내 지정이 무시됐는지 짚을 자리가 없다.
565
+ */
566
+ // 옛 인코딩 이름일 때만 말한다. UTF-8 · UTF-16 은 여기 오기 **전에** 이미 아니라고
567
+ // 따로 판정된 것이라, 후보에 없는 것이 설계대로다 — 그것을 「못 썼다」 고 적으면
568
+ // 없는 문제를 알리는 셈이 된다(거짓 경고도 결함이다).
569
+ const 옛이름인가 = (id) => LEGACY.some((x) => x.id === id);
570
+ const 못쓴힌트 = 적힌힌트 && 옛이름인가(적힌힌트) && !후보.some((x) => x.id === 적힌힌트) ? 적힌힌트 : null;
571
+ const 덧말 = 못쓴힌트 ? ` · 적어 준 ${못쓴힌트} 로는 이 바이트가 안 읽혀 안 썼습니다` : '';
572
+
211
573
  const 으뜸 = 후보[0];
212
574
  const 힌트것 = 후보.find((x) => x.id === 힌트);
213
575
  if (힌트것 && 힌트것 !== 으뜸 && 으뜸.score - 힌트것.score < 박빙) {
214
576
  return { id: 힌트것.id, sure: false, why: `${으뜸.id} 와 박빙이라 힌트를 따름`, 후보 };
215
577
  }
216
578
  const 여유 = 후보.length > 1 ? 으뜸.score - 후보[1].score : Infinity;
217
- return { id: 으뜸.id, sure: false, why: `내용으로 짐작 (${여유 === Infinity ? '단독' : `${여유.toFixed(1)}점 차`})`, 후보 };
579
+ return { id: 으뜸.id, sure: false, why: `내용으로 짐작 (${여유 === Infinity ? '단독' : `${여유.toFixed(1)}점 차`})${덧말}`, 후보 };
218
580
  }
219
581
 
220
582
  // 이 컴퓨터가 쓰는 옛 인코딩. 콘솔 코드페이지를 물어봐서 정한다.
@@ -260,8 +622,8 @@ export function consoleCodepage() {
260
622
  * encode() 에는 'utf-8-bom' 을 받는 자리가 처음부터 있었다. 다만 그 이름을
261
623
  * 만들어 주는 곳이 없어서 한 번도 안 불렸다 — 끊어져 있던 길을 여기서 잇는다.
262
624
  */
263
- export function decode(buf, { fallback = null, system = null } = {}) {
264
- const found = detect(buf, { fallback, system });
625
+ export function decode(buf, { fallback = null, system = null, 잘림 = false } = {}) {
626
+ const found = detect(buf, { fallback, system, 잘림 });
265
627
  const body = found.bom ? buf.subarray(found.bom) : buf;
266
628
  let text;
267
629
  try {
@@ -270,7 +632,18 @@ export function decode(buf, { fallback = null, system = null } = {}) {
270
632
  text = body.toString('utf8');
271
633
  return { text, encoding: 'utf-8', sure: false, why: `${found.id} 를 이 Node 가 모름` };
272
634
  }
273
- const 되돌릴이름 = found.id === 'utf-8' && found.bom ? 'utf-8-bom' : found.id;
635
+ /*
636
+ * UTF-16 도 표식 유무를 이름에 담는다.
637
+ *
638
+ * 여태 `encode('utf-16le')` 는 표식을 **언제나** 붙였다. 원본에 표식이
639
+ * 있는 파일만 여기까지 왔으니 그때는 맞았는데, 이제 표식 없는 것도
640
+ * 들어온다. 그대로 두면 한 글자 고쳤을 뿐인데 앞머리에 두 바이트가 생긴다 —
641
+ * UTF-8 쪽에서 이미 겪고 고쳐 둔 바로 그 자리다.
642
+ */
643
+ const 표식붙은이름 = (id) => (found.bom ? `${id}-bom` : id);
644
+ const 되돌릴이름 = found.id === 'utf-8' || found.id === 'utf-16le' || found.id === 'utf-16be'
645
+ ? 표식붙은이름(found.id)
646
+ : found.id;
274
647
  return { text, encoding: 되돌릴이름, sure: found.sure, why: found.why, bom: found.bom ?? 0 };
275
648
  }
276
649
 
@@ -294,18 +667,45 @@ function reverseTable(id) {
294
667
  } catch { /* 이 바이트 혼자로는 글자가 아니다 */ }
295
668
  }
296
669
  // 두 바이트짜리
670
+ const 미룸 = [];
297
671
  for (let hi = 0x81; hi <= 0xFE; hi++) {
298
672
  for (let lo = 0x40; lo <= 0xFE; lo++) {
299
673
  try {
300
674
  const ch = dec.decode(Uint8Array.of(hi, lo));
301
- if (ch.length === 1 && !map.has(ch)) map.set(ch, Uint8Array.of(hi, lo));
675
+ if (ch.length !== 1) continue;
676
+ if (뒷자리인가(id, hi)) { 미룸.push([ch, hi, lo]); continue; }
677
+ if ((마지막자리글자[id]?.has(ch)) || !map.has(ch)) map.set(ch, Uint8Array.of(hi, lo));
302
678
  } catch { /* 없는 조합 */ }
303
679
  }
304
680
  }
681
+ // 뒷자리밖에 없는 글자는 그 자리로라도 쓴다 — 못 쓴다고 하는 것보다 낫다.
682
+ for (const [ch, hi, lo] of 미룸) if (!map.has(ch)) map.set(ch, Uint8Array.of(hi, lo));
305
683
  _tables.set(id, map);
306
684
  return map;
307
685
  }
308
686
 
687
+ /*
688
+ * ── 한 글자에 바이트 자리가 둘인 것 — **표준 자리**를 고른다 ─────────────
689
+ *
690
+ * 역표를 「먼저 만난 것」 으로 채우고 있었다. 바이트 순으로 돌기 때문에
691
+ * Big5 의 十 은 호환 자리 A2CC 가 표준 자리 A451 보다 먼저 걸렸다. 그래서
692
+ * 새로 쓰는 十 이 전부 A2CC 로 나갔다 — 다른 도구로 검색하면 안 걸리는 十 이다.
693
+ *
694
+ * 브라우저·Node 가 쓰는 WHATWG 인코더와 같은 규칙으로 맞춘다.
695
+ * Big5 : 앞 바이트가 0xA1 아래(HKSCS)인 자리는 뒤로 미룬다. 그리고
696
+ * 아래 여섯 글자는 **마지막** 자리를 쓴다(十·卅·괘선 넷).
697
+ * Shift_JIS : 0xED~0xEF (NEC 가 고른 IBM 확장 — 0xFA 쪽과 겹친다)는 뒤로 미룬다.
698
+ *
699
+ * 이것만으로는 **이미 파일에 있는** 호환 자리를 못 지킨다. 그건 아래
700
+ * 바꾼데만쓰기 가 지킨다 — 손 안 댄 바이트는 역표를 아예 안 거친다.
701
+ */
702
+ const 마지막자리글자 = { big5: new Set(['═', '╞', '╡', '╪', '十', '卅']) };
703
+ function 뒷자리인가(id, hi) {
704
+ if (id === 'big5') return hi < 0xA1;
705
+ if (id === 'shift_jis') return hi >= 0xED && hi <= 0xEF;
706
+ return false;
707
+ }
708
+
309
709
  /**
310
710
  * 글을 바이트로. 읽을 때와 같은 인코딩으로 되돌린다.
311
711
  *
@@ -313,20 +713,39 @@ function reverseTable(id) {
313
713
  * 사용자는 그 사실을 모른 채 원본을 잃는다. 조용히 망가뜨리느니 멈추는 게 낫다.
314
714
  */
315
715
  export function encode(text, encoding = 'utf-8') {
316
- const id = String(encoding).toLowerCase();
317
-
318
- if (id === 'utf-8' || id === 'utf8') return { buf: Buffer.from(text, 'utf8'), lost: [] };
716
+ /*
717
+ * 하이픈은 있어도 없어도 같은 이름이다 (8회차 · 바깥).
718
+ *
719
+ * 여기는 `utf-8` 과 `utf8` 은 둘 다 받으면서 UTF-16 만 하이픈이 든 꼴 하나씩만
720
+ * 알았다. 그런데 Node 가 쓰는 이름이 `utf16le` 라, 그 이름으로 들어온 글이 이 문을
721
+ * 다 지나쳐 아래 역표 만들기로 떨어졌다 — 역표가 없으니 fellBack 이 서고 **조용히
722
+ * UTF-8 바이트**가 나왔다. 바로 아래에 「한 글자 고쳤을 뿐인데 파일 전체가 다른
723
+ * 인코딩이 되는 자리라 여기서 막는다」 고 적어 둔 그 막이, 하이픈 하나에 샜다.
724
+ * 이름정리() 가 보는 꼴과 같게 맞춘다 — 거기는 `-bom` 을 떼므로 여기서 따로 본다.
725
+ */
726
+ const id = String(encoding).toLowerCase()
727
+ .replace(/^utf-?8(?=$|-bom$)/, 'utf-8')
728
+ .replace(/^utf-?16-?(le|be)(?=$|-bom$)/, 'utf-16$1');
729
+
730
+ if (id === 'utf-8') return { buf: Buffer.from(text, 'utf8'), lost: [] };
319
731
  if (id === 'utf-8-bom') {
320
732
  return { buf: Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(text, 'utf8')]), lost: [] };
321
733
  }
322
- if (id === 'utf-16le') return { buf: Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from(text, 'utf16le')]), lost: [] };
323
- if (id === 'utf-16be') {
734
+ /*
735
+ * 표식은 **원본에 있던 것만** 붙인다. `-bom` 이 붙은 이름이 그 뜻이다.
736
+ * 없던 파일에 붙이면 앞머리 두 바이트가 늘고, 그건 도구가 마음대로 한 변경이다.
737
+ */
738
+ if (id === 'utf-16le' || id === 'utf-16le-bom') {
739
+ const le = Buffer.from(text, 'utf16le');
740
+ return { buf: id.endsWith('-bom') ? Buffer.concat([Buffer.from([0xFF, 0xFE]), le]) : le, lost: [] };
741
+ }
742
+ if (id === 'utf-16be' || id === 'utf-16be-bom') {
324
743
  // Node 는 utf16be 로 쓸 줄 모른다. LE 로 쓰고 두 바이트씩 뒤집으면 그게 BE 다.
325
744
  // 없으면 아래 역표 만들기로 떨어지고, 그건 실패해서 조용히 UTF-8 이 된다 —
326
745
  // 한 글자 고쳤을 뿐인데 파일 전체가 다른 인코딩이 되는 자리라 여기서 막는다.
327
746
  const le = Buffer.from(text, 'utf16le');
328
747
  for (let i = 0; i + 1 < le.length; i += 2) { const t = le[i]; le[i] = le[i + 1]; le[i + 1] = t; }
329
- return { buf: Buffer.concat([Buffer.from([0xFE, 0xFF]), le]), lost: [] };
748
+ return { buf: id.endsWith('-bom') ? Buffer.concat([Buffer.from([0xFE, 0xFF]), le]) : le, lost: [] };
330
749
  }
331
750
 
332
751
  let map;
@@ -344,12 +763,90 @@ export function encode(text, encoding = 'utf-8') {
344
763
  return { buf: Buffer.from(out), lost: [...lost] };
345
764
  }
346
765
 
347
- /** 화면에 적을 짧은 이름. 'CP949' 처럼 사람이 아는 말로. */
766
+ /**
767
+ * 옛 글을 새 글로 바꿔 쓸 바이트. **안 바뀐 앞뒤는 읽은 바이트를 그대로** 쓴다.
768
+ *
769
+ * ── 왜 통째로 encode 하면 안 되나 ──────────────────────────────────────
770
+ *
771
+ * Edit·Write 는 고친 글 **전체**를 역표로 다시 만들었다. 그런데 옛 인코딩에는
772
+ * 한 글자에 바이트 자리가 둘 이상인 것이 있다 — Shift_JIS 에서만 398자,
773
+ * Big5 10자, GBK 2자. 역표는 그중 한 자리만 알므로, 파일에 다른 자리로 적혀
774
+ * 있던 글자는 **한 글자도 안 고쳤는데 바이트가 바뀐다.**
775
+ *
776
+ * Shift_JIS 문서 … 計算結果は ≒(8790) 百です。確認 …
777
+ * 「確認」 을 「承認」 으로 Edit
778
+ * → ≒ 가 81E0 으로 바뀐다. 글자로는 같아서 /diff 에는 안 뜬다.
779
+ *
780
+ * 바이트로 견주는 쪽(사내 검색·대조·서명·git)에서만 깨지고, 사람은 고친 한
781
+ * 줄만 봤으니 이을 길이 없다. 「읽은 그대로 되돌려 쓴다」 는 이 파일의 규칙이
782
+ * 글자 단위로만 지켜지고 있었다.
783
+ *
784
+ * 그래서 옛 글과 새 글의 같은 앞·같은 뒤를 잘라 내고 **가운데만** 역표로
785
+ * 만든다. 앞뒤 바이트 길이는 그 글을 역표로 만든 길이로 잰다 — 자리가 둘인
786
+ * 글자도 두 바이트씩이라 길이는 같다. 마지막으로 되풀어 봐서 새 글과 똑같지
787
+ * 않으면(길이가 어긋나는 드문 글자) 여태처럼 통째로 만든 것을 쓴다.
788
+ *
789
+ * 못 옮기는 글자(lost)·인코더 없음(fellBack)은 통째로 만든 결과를 그대로
790
+ * 돌려준다 — 부르는 쪽의 거절 갈래가 그 값을 본다.
791
+ */
792
+ export function 바꾼데만쓰기(원바이트, 옛글, 새글, encoding) {
793
+ // 사람이 아는 이름(`cp949`·`CP949`·`ms949`)으로 불러도 바이트를 지킨다 (EB2-후속 · 8회차).
794
+ // 소문자로만 내리면 `cp949` 가 LEGACY 에 안 걸려 아래 지키기를 통째로 건너뛰었다 —
795
+ // 고친 한 줄만 보이는 화면 뒤에서 **파일 전체가 UTF-8 로 다시 써졌다.**
796
+ // 이름정리 가 이미 아는 별명을 이 문 앞에서만 안 쓰고 있었다.
797
+ //
798
+ // 이 줄이 `통째` 보다 **위에** 있어야 한다. encode 를 날이름으로 부르면 그 자리에서
799
+ // 이미 fellBack 이 서고, 바로 아래 `if (통째.fellBack …) return` 이 지키기를 건너뛴다.
800
+ // 다만 **표식(BOM)이 붙은 이름은 이름정리에 안 넘긴다.** 이름정리 는 힌트를 고르는
801
+ // 자라 `-bom` 을 떼어서 돌려준다(`utf-8-bom` → `utf-8`). 그 값을 encode 에 주면
802
+ // BOM 붙은 파일이 BOM 없이 다시 써졌다 — 엑셀이 내보낸 CSV · .ps1 · 메모장
803
+ // UTF-16LE 가 한 글자 Edit 만으로 앞 두세 바이트를 잃었다. encode 는 `-bom` 을
804
+ // 이미 알아들으니 날이름 그대로 넘기면 된다.
805
+ const 날이름 = String(encoding).toLowerCase();
806
+ const id = /-bom$/.test(날이름) ? 날이름 : (이름정리(encoding) ?? 날이름);
807
+ const 통째 = encode(새글, id);
808
+ if (통째.fellBack || 통째.lost.length) return 통째;
809
+ if (!LEGACY.some((x) => x.id === id) || !Buffer.isBuffer(원바이트) || typeof 옛글 !== 'string') return 통째;
810
+
811
+ const 짧은 = Math.min(옛글.length, 새글.length);
812
+ let 앞 = 0;
813
+ while (앞 < 짧은 && 옛글.charCodeAt(앞) === 새글.charCodeAt(앞)) 앞 += 1;
814
+ let 뒤 = 0;
815
+ while (뒤 < 짧은 - 앞 && 옛글.charCodeAt(옛글.length - 1 - 뒤) === 새글.charCodeAt(새글.length - 1 - 뒤)) 뒤 += 1;
816
+
817
+ const 앞것 = encode(옛글.slice(0, 앞), id);
818
+ const 뒤것 = encode(옛글.slice(옛글.length - 뒤), id);
819
+ // 원래 글에 역표로 못 옮기는 글자가 있으면(깨진 바이트) 자리를 잴 수 없다.
820
+ if (앞것.lost.length || 뒤것.lost.length) return 통째;
821
+ if (앞것.buf.length + 뒤것.buf.length > 원바이트.length) return 통째;
822
+ const 가운데 = encode(새글.slice(앞, 새글.length - 뒤), id);
823
+ const buf = Buffer.concat([
824
+ 원바이트.subarray(0, 앞것.buf.length),
825
+ 가운데.buf,
826
+ 원바이트.subarray(원바이트.length - 뒤것.buf.length),
827
+ ]);
828
+ try {
829
+ if (new TextDecoder(id, { fatal: true }).decode(buf) === 새글) return { buf, lost: [] };
830
+ } catch { /* 자리가 어긋났다 — 통째로 만든 것을 쓴다 */ }
831
+ return 통째;
832
+ }
833
+
834
+ /**
835
+ * 화면에 적을 짧은 이름. 'CP949' 처럼 사람이 아는 말로.
836
+ *
837
+ * 받은 id 를 **소문자로 낮춰 보고** 고른다. 안 낮추던 때는 사람이나 설정이 대문자로
838
+ * 적어 둔 이름(`EUC-KR`·`UTF-8-BOM`)이 아래 어느 줄에도 안 걸려서, 사람이 아는 말로
839
+ * 바꿔 주라고 있는 함수가 받은 글자를 그대로 되뱉었다 — 같은 파일이 자리에 따라
840
+ * `CP949` 로도 `EUC-KR` 로도 찍혔다. 이름을 고르는 쪽(이름정리)은 이미 낮춰 본다.
841
+ */
348
842
  export function label(id) {
349
- if (id === 'utf-8') return 'UTF-8';
350
- if (id === 'utf-8-bom') return 'UTF-8(BOM)';
351
- if (id === 'utf-16le') return 'UTF-16LE';
352
- if (id === 'utf-16be') return 'UTF-16BE';
353
- const f = LEGACY.find((x) => x.id === id);
843
+ const 낮춘것 = String(id ?? '').toLowerCase();
844
+ if (낮춘것 === 'utf-8') return 'UTF-8';
845
+ if (낮춘것 === 'utf-8-bom') return 'UTF-8(BOM)';
846
+ if (낮춘것 === 'utf-16le') return 'UTF-16LE';
847
+ if (낮춘것 === 'utf-16be') return 'UTF-16BE';
848
+ if (낮춘것 === 'utf-16le-bom') return 'UTF-16LE(BOM)';
849
+ if (낮춘것 === 'utf-16be-bom') return 'UTF-16BE(BOM)';
850
+ const f = LEGACY.find((x) => x.id === 낮춘것);
354
851
  return f ? `CP${f.cp}` : String(id).toUpperCase();
355
852
  }