aura-basetools 0.20.0 → 0.21.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.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * encoding.mjs —— 非 UTF-8 文本的**探测 / 解码 / 回编码**(零依赖)。
3
+ *
4
+ * ## 为什么这层存在
5
+ *
6
+ * 内置读写工具只认 UTF-8;CJK 老项目、GBK 配置、Windows 导出文件、UTF-16 的
7
+ * PowerShell 输出都读不了。反馈(2026-10-25)把它列为第一条产品边界。
8
+ *
9
+ * ## 为什么可以零依赖
10
+ *
11
+ * Node 的 `TextDecoder`(full ICU)**能解码** gbk / gb18030 / shift_jis / euc-kr /
12
+ * big5 / windows-1252 / utf-16le / utf-16be;但没有对应的 `TextEncoder`。
13
+ * 反向表可以**暴力建**:把双字节空间逐对解码,拿到“字符 → 字节”的映射。
14
+ * 实测(Node 24):GBK 23,939 条 / 11ms,Shift_JIS 9,269 条 / 7ms,EUC-KR 8,442 条 / 4ms。
15
+ * 每个编码只建一次,进程内缓存。
16
+ *
17
+ * ## 一条硬边界:**只写回编码可逆的文件**
18
+ *
19
+ * 同一个字符在一个编码里可能有多个字节表示(重复映射),GB18030 的 4 字节形式更是
20
+ * 覆盖到 BMP 之外。若 `decode → encode` 不能逐字节还原原文件,那么“改一行”可能会把
21
+ * **没改的行**也换一种字节写法——逐字节保真的承诺就破了。
22
+ *
23
+ * 所以 `probeEncoding` 会顺手做一次往返校验:不可逆的编码一律**只读**,
24
+ * 写操作拒绝(`ENCODING_NOT_ROUNDTRIP_SAFE`)并给出“先转成 UTF-8”的下一步。
25
+ * 这不是能力缺失,而是把“能读”与“能安全写”分开——后者才是本工具的价值。
26
+ */
27
+
28
+ /** BOM 按**从长到短**匹配:UTF-32LE 的前两字节与 UTF-16LE 相同。 */
29
+ const BOMS = [
30
+ { bytes: [0x00, 0x00, 0xfe, 0xff], encoding: 'utf-32be' },
31
+ { bytes: [0xff, 0xfe, 0x00, 0x00], encoding: 'utf-32le' },
32
+ { bytes: [0xef, 0xbb, 0xbf], encoding: 'utf-8' },
33
+ { bytes: [0xff, 0xfe], encoding: 'utf-16le' },
34
+ { bytes: [0xfe, 0xff], encoding: 'utf-16be' },
35
+ ]
36
+
37
+ /** 探测候选(顺序 = 并列时的优先级;windows-1252 是最终兜底,任何字节都能解码)。 */
38
+ export const ENCODING_CANDIDATES = ['gb18030', 'shift_jis', 'euc-kr', 'big5', 'windows-1252']
39
+
40
+ /** 本工具能读能写的编码(UTF-32 只读,不在此列)。`capabilities.textEncodings` 取这一份。 */
41
+ export const SUPPORTED_ENCODINGS = ['utf-8', 'utf-16le', 'utf-16be', 'gb18030', 'shift_jis', 'euc-kr', 'big5', 'windows-1252']
42
+
43
+ /** 不支持“回编码”的编码(Node 能解码,但这里不建表):UTF-32 只读。 */
44
+ const READ_ONLY_ENCODINGS = new Set(['utf-32le', 'utf-32be'])
45
+
46
+ const decoderCache = new Map()
47
+ function decoderFor(encoding) {
48
+ if (!decoderCache.has(encoding)) decoderCache.set(encoding, new TextDecoder(encoding))
49
+ return decoderCache.get(encoding)
50
+ }
51
+ function fatalDecoderFor(encoding) {
52
+ const key = `${encoding}:fatal`
53
+ if (!decoderCache.has(key)) decoderCache.set(key, new TextDecoder(encoding, { fatal: true }))
54
+ return decoderCache.get(key)
55
+ }
56
+
57
+ function bomOf(buffer) {
58
+ for (const bom of BOMS) {
59
+ if (buffer.length >= bom.bytes.length && bom.bytes.every((byte, index) => buffer[index] === byte)) {
60
+ return { encoding: bom.encoding, bytes: bom.bytes }
61
+ }
62
+ }
63
+ return null
64
+ }
65
+
66
+ /** ASCII 之外是否还有字节:纯 ASCII 不必猜,UTF-8 就是对的。 */
67
+ function isAscii(buffer) {
68
+ for (const byte of buffer) if (byte > 0x7f) return false
69
+ return true
70
+ }
71
+
72
+ /**
73
+ * 高频汉字(简 + 繁)。不是语言模型,只用来区分“解出来是真中文”与“解出来是随机汉字”:
74
+ * GBK 字节被错解成 Shift_JIS 时,得到的基本是生僻/不相关的汉字,命中率明显低。
75
+ */
76
+ const COMMON_HANZI = new Set(
77
+ ('的一是不了人我在有他这为之大来以个中上们到说国和地也子时道出而要于就下得可你年生' +
78
+ '看天年自子手何文头学过对面当起就看所以外中前定行方多月给出就明利得实行方都事家等' +
79
+ '信里现所点边法三小将机分那好行开通于发水成只作当想看太得知不少经现方事面而及全两' +
80
+ '体西气月教分战百见小报必马公将话次新十明个外员对原系最了事动成问高但自里外还分新' +
81
+ '的一是不了人我在有他這為之大來以個中上們到說國和地也子時道出而要於就下得可你年生' +
82
+ '看天年自子手何文頭學過對面當起就看所以外中前定行方多月給出就明利得實行方都事家等' +
83
+ '信裡現所點邊法三小將機分那好行開通於發水成只作當想看太得知不少經現方事面而及全兩').split(''),
84
+ )
85
+ const KANA = /[\u3040-\u30ff]/
86
+ const HANGUL = /[\uac00-\ud7af]/
87
+
88
+ /** 控制字符(除 \t \n \r)与私用区越多,越不像“正确的编码”。 */
89
+ function plausibility(text) {
90
+ let score = 0
91
+ let cjk = 0
92
+ for (const char of text) {
93
+ const code = char.codePointAt(0)
94
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) score += 200
95
+ else if (code === 0xfffd) score += 10000
96
+ else if (code >= 0xe000 && code <= 0xf8ff) score += 40
97
+ else if (code >= 0x4e00 && code <= 0x9fff) cjk += 1
98
+ else if (code >= 0x80 && code <= 0xff) score += 3
99
+ else if (code >= 0x2e80 && code <= 0x9fff) cjk += 0.5
100
+ }
101
+ return score - cjk
102
+ }
103
+
104
+ /**
105
+ * 编码专属加分:
106
+ * · Shift_JIS 文本几乎必带假名;
107
+ * · EUC-KR 文本几乎必带谚文(超范围,几乎不会误中);
108
+ * · GB18030 / Big5 用高频汉字命中率区分简繁。
109
+ * 注意:纯汉字短句(如“日本語”)本质上有歧义,所以分差小的时候报 `medium` 而不是硬猜。
110
+ */
111
+ function encodingBonus(text, encoding) {
112
+ let kana = 0
113
+ let hangul = 0
114
+ let commonHangul = 0
115
+ let common = 0
116
+ let simplified = 0
117
+ let traditional = 0
118
+ let script = 0
119
+ for (const char of text) {
120
+ if (KANA.test(char)) {
121
+ kana += 1
122
+ script += 1
123
+ } else if (HANGUL.test(char)) {
124
+ hangul += 1
125
+ if (COMMON_HANGUL.has(char)) commonHangul += 1
126
+ script += 1
127
+ } else {
128
+ const code = char.codePointAt(0)
129
+ if (code >= 0x4e00 && code <= 0x9fff) {
130
+ script += 1
131
+ if (COMMON_HANZI.has(char)) common += 1
132
+ if (SIMPLIFIED_ONLY.has(char)) simplified += 1
133
+ if (TRADITIONAL_ONLY.has(char)) traditional += 1
134
+ }
135
+ }
136
+ }
137
+ const total = Math.max(1, script)
138
+ const kanaRatio = kana / total
139
+ const hangulRatio = hangul / total
140
+ /** 真实韩文的高频音节占比很高;噪声谚文几乎命中不了。 */
141
+ const commonHangulRatio = commonHangul / total
142
+ const commonRatio = common / total
143
+ const simplifiedRatio = simplified / total
144
+ const traditionalRatio = traditional / total
145
+ /**
146
+ * 三次方:只有“接近纯某语言”才给大分。
147
+ * 理由:GBK 字节常常在 EUC-KR 里也“合法”,会解出几个谚文——线性计分会让它反超;
148
+ * 真实韩文/日文的假名/谚文占比接近 80%+,三次方把“少数噪声”压下去。
149
+ */
150
+ if (encoding === 'shift_jis') return kanaRatio ** 2 * 120 + commonRatio * 3
151
+ if (encoding === 'euc-kr') return commonHangulRatio ** 2 * 160 + hangulRatio * 4
152
+ if (encoding === 'gb18030') return commonRatio * 10 + simplifiedRatio * 8
153
+ if (encoding === 'big5') return commonRatio * 10 + traditionalRatio * 8
154
+ return 0
155
+ }
156
+
157
+ /**
158
+ * 高频谚文音节(韩国语常用 ~120 个)。
159
+ * 为什么需要它:GBK 字节在 EUC-KR 里常常也“合法”,会解出一堆**随机**谚文;
160
+ * 只看谚文比例会被噪声骗(实测两行中文被认成韩文)。真实韩文用高频音节,噪声不用。
161
+ */
162
+ const COMMON_HANGUL = new Set(
163
+ ('이다는의리가에하고한것은어지대로수도시정등그나아부게있하면서요까안않없되받사람우조회국민중소기업계획발전문제관리프램데터트워크컴퓨니스입테스트습').split(''),
164
+ )
165
+
166
+ /** 只简体 / 只繁体的常用字(区分 GB 与 Big5 的强信号)。 */
167
+ const SIMPLIFIED_ONLY = new Set('这个说国为时学过对给发经现点边将机开们来还问长见话让东风车书关门听写买卖应'.split(''))
168
+ const TRADITIONAL_ONLY = new Set('這個說國為時學過對給發經現點邊將機開們來還問長見話讓東風車書關門聽寫買賣應'.split(''))
169
+
170
+ /**
171
+ * 探测一个 buffer 的编码。
172
+ *
173
+ * @param buffer - 原始字节
174
+ * @param options.hint - 调用方显式指定(`--encoding` / MCP `encoding`),命中即用,不做猜测
175
+ * @returns {{encoding:string, bom:number[], confidence:'high'|'medium'|'low',
176
+ * alternatives:string[], roundTrip:boolean, readOnly:boolean}}
177
+ */
178
+ export function probeEncoding(buffer, { hint } = {}) {
179
+ if (typeof hint === 'string' && hint.length > 0) {
180
+ const normalized = hint.toLowerCase()
181
+ const encoding = normalized === 'latin1' || normalized === 'iso-8859-1' ? 'windows-1252' : normalized
182
+ const bom = bomOf(buffer)
183
+ return {
184
+ encoding,
185
+ bom: bom?.encoding === encoding ? bom.bytes : [],
186
+ confidence: 'high',
187
+ alternatives: [],
188
+ explicit: true,
189
+ ...roundTripVerdict(buffer, encoding, bom?.encoding === encoding ? bom.bytes : []),
190
+ readOnly: READ_ONLY_ENCODINGS.has(encoding),
191
+ }
192
+ }
193
+
194
+ const bom = bomOf(buffer)
195
+ if (bom !== null) {
196
+ // UTF-16/32 的正文里必然有 0 字节,不能再用“含 NUL 即二进制”的判据。
197
+ return {
198
+ encoding: bom.encoding,
199
+ bom: bom.bytes,
200
+ confidence: 'high',
201
+ alternatives: [],
202
+ ...roundTripVerdict(buffer, bom.encoding, bom.bytes),
203
+ readOnly: READ_ONLY_ENCODINGS.has(bom.encoding),
204
+ }
205
+ }
206
+
207
+ if (buffer.includes(0)) {
208
+ // 无 BOM 且含 NUL:UTF-16 猜不准端序,按二进制拒绝(与既有 FS_NOT_TEXT 行为一致)。
209
+ return { encoding: null, bom: [], confidence: 'high', alternatives: [], roundTrip: false, readOnly: true }
210
+ }
211
+
212
+ // 合法 UTF-8 就是 UTF-8:绝大多数文件走这条,行为与支持多编码之前**逐字节一致**。
213
+ try {
214
+ const text = fatalDecoderFor('utf-8').decode(buffer)
215
+ if (isAscii(buffer) || plausibility(text) <= 0) {
216
+ return { encoding: 'utf-8', bom: [], confidence: 'high', alternatives: [], roundTrip: true, readOnly: false }
217
+ }
218
+ // UTF-8 合法但“不像文本”(私用区/控制符):继续当 UTF-8——合法性比猜测强。
219
+ return { encoding: 'utf-8', bom: [], confidence: 'high', alternatives: [], roundTrip: true, readOnly: false }
220
+ } catch {
221
+ /* 不是合法 UTF-8,进入候选打分 */
222
+ }
223
+
224
+ const scored = []
225
+ for (const encoding of ENCODING_CANDIDATES) {
226
+ let text
227
+ try {
228
+ text = fatalDecoderFor(encoding).decode(buffer)
229
+ } catch {
230
+ continue
231
+ }
232
+ if (text.includes('\u0000')) continue
233
+ scored.push({ encoding, score: plausibility(text) - encodingBonus(text, encoding) })
234
+ }
235
+ if (scored.length === 0) {
236
+ return { encoding: null, bom: [], confidence: 'high', alternatives: [], roundTrip: false, readOnly: true }
237
+ }
238
+ scored.sort((a, b) => a.score - b.score)
239
+ const best = scored[0]
240
+ const second = scored[1]
241
+ // 分差太小 = 猜不准:报 `medium`,把候选交给调用方(`--encoding` 可覆盖)。
242
+ const confidence = second === undefined ? 'high' : second.score - best.score >= 5 ? 'high' : 'medium'
243
+ return {
244
+ encoding: best.encoding,
245
+ bom: [],
246
+ confidence,
247
+ alternatives: scored.slice(1, 4).map((entry) => entry.encoding),
248
+ ...roundTripVerdict(buffer, best.encoding, []),
249
+ readOnly: false,
250
+ }
251
+ }
252
+
253
+ /** 往返校验:解码→回编码能否逐字节还原(BOM 也算进去)。 */
254
+ function roundTripVerdict(buffer, encoding, bomBytes) {
255
+ if (READ_ONLY_ENCODINGS.has(encoding)) return { roundTrip: false }
256
+ try {
257
+ const text = decodeBytes(buffer, { encoding, bom: bomBytes })
258
+ /**
259
+ * 引擎的 BOM 约定:BOM 文件写入时,`writeText` 收到的 content **已经带一个 U+FEFF**
260
+ * (引擎把 BOM 当正文首字符再带一份;双/三 BOM 夹具靠这个逐字节保留)。
261
+ * 往返校验必须用同一约定——否则双 BOM 文件会被误判成“不可逆”,写入被拒。
262
+ */
263
+ const content = bomBytes.length > 0 ? `\uFEFF${text}` : text
264
+ const encoded = encodeText(content, { encoding, bom: bomBytes })
265
+ return { roundTrip: Buffer.compare(Buffer.from(encoded), Buffer.from(buffer)) === 0 }
266
+ } catch {
267
+ return { roundTrip: false }
268
+ }
269
+ }
270
+
271
+ /** 解码(剥掉 BOM;TextDecoder 默认 ignoreBOM:false 会吃掉一个前导 BOM)。 */
272
+ export function decodeBytes(buffer, probe) {
273
+ return decoderFor(probe.encoding).decode(Buffer.from(buffer))
274
+ }
275
+
276
+ /**
277
+ * GB18030 补充平面(U+10000..U+10FFFF)的 4 字节形式是**线性**映射:
278
+ * 码点 ⇄ 序号 `189000 + (cp - 0x10000)`,序号再按 12600/1260/10 拆成四字节。
279
+ * 实测 U+1F642 🙂 → 95 30 85 32、U+20000 → 95 32 82 36,解码回来逐字相同。
280
+ *
281
+ * BMP 的 4 字节区(如扩展 A 区)不是线性的,需要标准映射表;本工具不内置那张表,
282
+ * 遇到时仍拒绝(宁可拒绝,不可写错)。
283
+ */
284
+ function gb18030FourByte(codePoint) {
285
+ const index = codePoint - 0x10000 + 189000
286
+ const rest1 = index % 12600
287
+ const rest2 = rest1 % 1260
288
+ return [0x81 + Math.floor(index / 12600), 0x30 + Math.floor(rest1 / 1260), 0x81 + Math.floor(rest2 / 10), 0x30 + (rest2 % 10)]
289
+ }
290
+
291
+ /** 反向表:字符 → 字节序列。懒建、进程内缓存。 */
292
+ const reverseCache = new Map()
293
+ export function reverseTable(encoding) {
294
+ if (reverseCache.has(encoding)) return reverseCache.get(encoding)
295
+ const decoder = decoderFor(encoding)
296
+ const table = new Map()
297
+ // 单字节:ASCII 直通;0x80-0xFF 逐个解(半角片假名、Latin-1 补区都在这里)。
298
+ for (let byte = 0x00; byte <= 0xff; byte += 1) {
299
+ const char = decoder.decode(Buffer.from([byte]))
300
+ if (char.length === 1 && char !== '\ufffd') table.set(char, [byte])
301
+ }
302
+ // 双字节:只扫合法引导区,避免 6 万次全空间解码。
303
+ const isUtf16 = encoding === 'utf-16le' || encoding === 'utf-16be'
304
+ if (!isUtf16) {
305
+ for (let high = 0x81; high <= 0xfe; high += 1) {
306
+ for (let low = 0x40; low <= 0xfe; low += 1) {
307
+ const char = decoder.decode(Buffer.from([high, low]))
308
+ if (char.length === 1 && char !== '\ufffd' && !table.has(char)) table.set(char, [high, low])
309
+ }
310
+ }
311
+ }
312
+ reverseCache.set(encoding, table)
313
+ return table
314
+ }
315
+
316
+ /**
317
+ * 回编码。不可表示 / 不可逆时**抛错**,绝不静默替换成 '?'——那会把用户的字节改掉。
318
+ *
319
+ * @throws Error(code: ENCODING_UNREPRESENTABLE / ENCODING_NOT_ROUNDTRIP_SAFE)
320
+ */
321
+ export function encodeText(text, probe) {
322
+ const { encoding, bom = [] } = probe
323
+ if (encoding === null || encoding === undefined) {
324
+ throw Object.assign(new Error('cannot encode: unknown source encoding'), { code: 'ENCODING_UNKNOWN' })
325
+ }
326
+ if (READ_ONLY_ENCODINGS.has(encoding)) {
327
+ throw Object.assign(new Error(`cannot write ${encoding}: this encoding is read-only here; convert the file to UTF-8 first`), {
328
+ code: 'ENCODING_NOT_ROUNDTRIP_SAFE',
329
+ })
330
+ }
331
+ /**
332
+ * BOM 归谁:引擎在 BOM 文件上会把 BOM 当**正文的第一个字符**带进 content(历史上如此,
333
+ * 双 BOM 夹具靠它逐字节保留)。所以:
334
+ * · content 已以 U+FEFF 开头 ⇒ 它自己就是 BOM,**不要再加前缀**;
335
+ * · 否则用探测到的 BOM 字节补上。
336
+ * 早先“先剥掉再加前缀”会把双 BOM 文件断成一个 BOM(往返校验当场发现 ⇒ 写被拒)。
337
+ */
338
+ const prefix = bom.length > 0 && !text.startsWith('\uFEFF') ? Buffer.from(bom) : Buffer.alloc(0)
339
+ const payload = text
340
+ let body
341
+ if (encoding === 'utf-8') {
342
+ body = Buffer.from(payload, 'utf8')
343
+ } else if (encoding === 'utf-16le') {
344
+ body = Buffer.alloc(payload.length * 2)
345
+ for (let i = 0; i < payload.length; i += 1) body.writeUInt16LE(payload.charCodeAt(i), i * 2)
346
+ } else if (encoding === 'utf-16be') {
347
+ body = Buffer.alloc(payload.length * 2)
348
+ for (let i = 0; i < payload.length; i += 1) body.writeUInt16BE(payload.charCodeAt(i), i * 2)
349
+ } else {
350
+ const table = reverseTable(encoding)
351
+ const chunks = []
352
+ const missing = []
353
+ for (const char of payload) {
354
+ let bytes = table.get(char)
355
+ if (bytes === undefined) {
356
+ const codePoint = char.codePointAt(0)
357
+ // GB18030 的补充平面(emoji / 扩展 B)用 4 字节线性形式,不必查表。
358
+ if (encoding === 'gb18030' && codePoint >= 0x10000) bytes = gb18030FourByte(codePoint)
359
+ }
360
+ if (bytes === undefined) {
361
+ missing.push(char)
362
+ if (missing.length >= 8) break
363
+ continue
364
+ }
365
+ chunks.push(Buffer.from(bytes))
366
+ }
367
+ if (missing.length > 0) {
368
+ const shown = missing.map((char) => `${JSON.stringify(char)}(U+${char.codePointAt(0).toString(16).toUpperCase()})`).join(', ')
369
+ throw Object.assign(
370
+ new Error(`cannot encode to ${encoding}: ${shown} ${missing.length === 1 ? 'has' : 'have'} no mapping in this encoding — nothing was written; convert the file to UTF-8 (or replace those characters) first`),
371
+ { code: 'ENCODING_UNREPRESENTABLE', characters: missing, encoding },
372
+ )
373
+ }
374
+ body = Buffer.concat(chunks)
375
+ }
376
+ return prefix.length === 0 ? body : Buffer.concat([prefix, body])
377
+ }
378
+
379
+ /**
380
+ * 能不能安全回写:编码可逆 + 不是只读编码 + **不是猜不准的**(除非调用方显式指定)。
381
+ *
382
+ * 中/低置信度下回写 = 把“猜错的编码”写回盘上,宁可拒绝。
383
+ */
384
+ export function canWrite(probe) {
385
+ if (probe === null || probe === undefined) return false
386
+ if (probe.encoding === null || probe.encoding === undefined) return false
387
+ if (probe.readOnly === true || probe.roundTrip !== true) return false
388
+ return probe.explicit === true || probe.confidence === 'high'
389
+ }
390
+
391
+ /** 为什么不能写(给回执用的可执行理由);能写返回 null。 */
392
+ export function writeRefusal(probe) {
393
+ if (canWrite(probe)) return null
394
+ if (probe?.encoding === null || probe?.encoding === undefined) return { code: 'FS_NOT_TEXT', reason: 'the file is not text' }
395
+ if (probe.readOnly === true || probe.roundTrip !== true) {
396
+ return { code: 'ENCODING_NOT_ROUNDTRIP_SAFE', reason: `${probe.encoding}: decoding/encoding would not reproduce the file byte-for-byte` }
397
+ }
398
+ return {
399
+ code: 'ENCODING_AMBIGUOUS',
400
+ reason: `detected ${probe.encoding} with ${probe.confidence} confidence (alternatives: ${probe.alternatives.join(', ') || 'none'}) — pass the encoding explicitly to write`,
401
+ }
402
+ }
403
+
404
+ /** 供回执/诊断用的一行摘要。 */
405
+ export function describeProbe(probe) {
406
+ if (probe?.encoding === null || probe?.encoding === undefined) return 'binary or undecodable'
407
+ const bom = probe.bom.length > 0 ? '+bom' : ''
408
+ const extra = probe.roundTrip ? '' : ' (read-only: not byte-reversible)'
409
+ return `${probe.encoding}${bom}${extra}`
410
+ }
@@ -27,6 +27,8 @@
27
27
  import fs from 'node:fs'
28
28
  import path from 'node:path'
29
29
  import crypto from 'node:crypto'
30
+ import { probeEncoding, decodeBytes, encodeText, writeRefusal } from './encoding.mjs'
31
+ import { recallEncoding, rememberEncoding } from './encoding-memory.mjs'
30
32
 
31
33
  const SAMPLE = 4096
32
34
  /** 小文件走全文哈希;大文件才退回头尾采样。 */
@@ -205,6 +207,10 @@ function resolveWriteTarget(target) {
205
207
  export function createNodeFsProvider(options = {}) {
206
208
  const root = path.resolve(options.resolveRoot ?? process.cwd())
207
209
  const abs = (p) => (path.isAbsolute(p) ? path.normalize(p) : path.resolve(root, p))
210
+ /** 显式编码(`--encoding` / MCP `encoding` / `AURA_ENCODING`):只在“猜不准”的文件上用。 */
211
+ let encodingHint = options.encoding ?? process.env.AURA_ENCODING
212
+ /** 读时探测到的编码,写回时沿用(按绝对路径缓存)。 */
213
+ const probes = new Map()
208
214
  const configuredMax = options.maxTextBytes ?? Number(process.env.AURA_MAX_FILE_BYTES)
209
215
  const maxTextBytes = Number.isSafeInteger(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_TEXT_BYTES
210
216
 
@@ -237,10 +243,39 @@ export function createNodeFsProvider(options = {}) {
237
243
  resolve: (p) => abs(p),
238
244
  stat,
239
245
  async readText(target) {
240
- const buffer = readOrThrow(abs(target), maxTextBytes)
241
- if (buffer.includes(0)) throw Object.assign(new Error(`cannot read "${target}": binary file`), { code: 'FS_NOT_TEXT' })
242
- // 与 dsh-fs-local 同形:fatal 解码 + 默认 ignoreBOM(第一个 BOM 被吃掉,其余 U+FEFF 留在文本里)
243
- return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
246
+ const file = abs(target)
247
+ const buffer = readOrThrow(file, maxTextBytes)
248
+ /**
249
+ * 多编码:自动探测(BOM → 合法 UTF-8 → 候选打分)。UTF-8 路径与支持多编码之前一致:
250
+ * 合法 UTF-8 一律走 utf-8,`decodeBytes` 同一个 TextDecoder,逐字节行为不变。
251
+ * 探测结果按绝对路径记住,`writeText` 用同一次探测回写(避免读一次写一次猜两次)。
252
+ */
253
+ const st = fs.statSync(file)
254
+ const effectiveHint = encodingHint ?? recallEncoding(file, st)
255
+ const probe = probeEncoding(buffer, { hint: effectiveHint })
256
+ if (probe.encoding === null) throw Object.assign(new Error(`cannot read "${target}": binary file`), { code: 'FS_NOT_TEXT' })
257
+ // 显式指定过一次就记住;记忆只是加速器,指纹对不上会自动失效。
258
+ if (encodingHint !== undefined) rememberEncoding(file, probe.encoding, st)
259
+ probes.set(file, probe)
260
+ return decodeBytes(buffer, probe)
261
+ },
262
+ /**
263
+ * 逐调用覆盖编码(MCP 的 `encoding` 参数):同一进程长驻服务,不能靠环境变量。
264
+ * 传空值 = 清掉本次覆盖,回到环境变量/记忆。
265
+ */
266
+ setEncodingHint(hint) {
267
+ encodingHint = typeof hint === 'string' && hint.length > 0 ? hint : process.env.AURA_ENCODING
268
+ },
269
+ /**
270
+ * 可选方法:把文本编码成**这个文件将要用到的字节**(写后校验用它)。
271
+ *
272
+ * 引擎的 `verifyWriteBack` 不能假设 UTF-8——GBK/UTF-16 文件写下去的是别的字节;
273
+ * 让提供方回答“你会写成什么”,校验才是同一份事实。DSH 提供方没有这个方法时回退到 UTF-8。
274
+ */
275
+ encodeFor(target, content) {
276
+ const file = abs(target)
277
+ const probe = probes.get(file) ?? { encoding: 'utf-8', bom: [], confidence: 'high', roundTrip: true, readOnly: false, explicit: false }
278
+ return encodeText(content, probe)
244
279
  },
245
280
  async readByteRange(target, range) {
246
281
  const file = abs(target)
@@ -308,7 +343,24 @@ export function createNodeFsProvider(options = {}) {
308
343
  },
309
344
  async writeText(target, content, expected) {
310
345
  const display = target
311
- const bytes = Buffer.byteLength(content)
346
+ const file = abs(target)
347
+ /**
348
+ * 回写编码 = 读时那次探测(没读过的新文件按 UTF-8)。
349
+ * “猜不准就不写”(`ENCODING_AMBIGUOUS`)与“不可逆就不写”(`ENCODING_NOT_ROUNDTRIP_SAFE`)
350
+ * 都在这里拦住——调用方显式给 `encoding` 才能继续。
351
+ */
352
+ let probe = probes.get(file)
353
+ if (probe === undefined) {
354
+ probe = fs.existsSync(file)
355
+ ? probeEncoding(fs.readFileSync(file), { hint: encodingHint })
356
+ : { encoding: 'utf-8', bom: [], confidence: 'high', roundTrip: true, readOnly: false, explicit: false }
357
+ }
358
+ const refusal = writeRefusal(probe)
359
+ if (refusal !== null) {
360
+ throw Object.assign(new Error(`cannot patch "${display}": ${refusal.reason} — nothing was written`), { code: refusal.code, encoding: probe.encoding })
361
+ }
362
+ const buffer = encodeText(content, probe)
363
+ const bytes = buffer.length
312
364
  if (bytes > maxTextBytes) {
313
365
  throw Object.assign(
314
366
  new Error(`cannot patch "${display}": content is ${bytes} bytes, over the ${maxTextBytes}-byte text limit`),
@@ -344,11 +396,13 @@ export function createNodeFsProvider(options = {}) {
344
396
  }
345
397
  }
346
398
  }
347
- writeAtomic(resolved.file, content, {
399
+ writeAtomic(resolved.file, buffer, {
348
400
  mode: resolved.exists ? resolved.stat.mode & 0o7777 : null,
349
401
  beforeRename,
350
402
  })
351
403
  const after = fs.statSync(resolved.file)
404
+ // 写完刷新记忆的指纹:否则下一次读到旧 stamp,记忆会被当成失效而回到“猜”。
405
+ if (probe.encoding !== 'utf-8') rememberEncoding(resolved.file, probe.encoding, after)
352
406
  return { version: fingerprintFile(resolved.file, after) }
353
407
  },
354
408
  }
package/lib/ops.mjs CHANGED
@@ -16,6 +16,8 @@ import { createNodeFsProvider, DEFAULT_MAX_TEXT_BYTES } from './fs-provider.mjs'
16
16
  import { withWorkspaceLock } from './workspace-lock.mjs'
17
17
  import { createTransaction, getTransactionMeta, openTransaction } from './transaction-store.mjs'
18
18
  import { createDiskStore } from './observation-store.mjs'
19
+ import { probeEncoding, decodeBytes } from './encoding.mjs'
20
+ import { recallEncoding, rememberEncoding } from './encoding-memory.mjs'
19
21
 
20
22
  /**
21
23
  * ── 引擎位置:两种布局各一个候选,选一次之后就冻住 ─────────────────────────────
@@ -140,6 +142,7 @@ export function capabilitiesEnvelope() {
140
142
  * 「谁去定位引擎」的地方(少一处 = 少一处会在另一种布局下红的引用)。
141
143
  */
142
144
  export const readWith = readCore.readWith
145
+ export const MODES = readCore.MODES
143
146
  export const setObservationStore = readCore.setObservationStore
144
147
  export const BYTE_ORDER_MARK_BYTES = readCore.BYTE_ORDER_MARK_BYTES
145
148
  export const collectRepoMap = repoMap.collectRepoMap
@@ -277,13 +280,13 @@ export function installDiskObservations() {
277
280
  installed = true
278
281
  }
279
282
 
280
- export function openProvider(resolveRoot) {
283
+ export function openProvider(resolveRoot, options = {}) {
281
284
  installDiskObservations()
282
- return createNodeFsProvider({ resolveRoot })
285
+ return createNodeFsProvider({ resolveRoot, ...options })
283
286
  }
284
287
 
285
288
  /** 与提供方同形地读一份文件:fatal 解码 + 第一个 BOM 不进字符串;二进制拒绝。 */
286
- export function readFileBytes(displayPath) {
289
+ export function readFileBytes(displayPath, { encoding: hint } = {}) {
287
290
  let stat
288
291
  try {
289
292
  stat = fs.statSync(displayPath)
@@ -303,18 +306,34 @@ export function readFileBytes(displayPath) {
303
306
  }
304
307
  }
305
308
  const bytes = fs.readFileSync(displayPath)
306
- if (bytes.includes(0)) {
307
- return { error: { ok: false, error: `cannot read "${displayPath}": binary file`, code: 'FS_NOT_TEXT' } }
308
- }
309
- const byteOrderMark = bytes.length >= BYTE_ORDER_MARK_BYTES.length && BYTE_ORDER_MARK_BYTES.every((b, i) => bytes[i] === b)
310
309
  /**
311
- * C55:解码失败原来直接抛裸 `TypeError`(`The encoded data was not valid for encoding utf-8`),
312
- * 一路浮到 CLI/MCP 就只剩这一句——不说哪个文件、不说下一步。现在在这里换成可执行的拒绝。
313
- * 码**原样保留**(`ERR_ENCODING_INVALID_ENCODED_DATA`):调用方与既有判据靠它区分
314
- * 「不是 UTF-8」与别的工具级故障(`range-bytes-test` 段D 就钉着它)。
310
+ * 多编码(2026-10-25 反馈第一条):BOM → 合法 UTF-8 → 候选打分;
311
+ * 合法 UTF-8 一律走 utf-8,与支持多编码之前的字节行为完全一致。
312
+ * 猜不准(medium/low)仍可读,但回执会把 `encoding` 与 `encoding_confidence` 一并给出,
313
+ * 写回那侧会拒绝——不让“猜错的编码”变成“写错的盘”。
314
+ */
315
+ /**
316
+ * 提示的优先级:本次调用 > 环境变量 > 文件级记忆(指纹对得上才用)。
317
+ * 显式给过(调用参数或环境变量)就记住——下次同一文件不必再设。
315
318
  */
319
+ const explicitHint = hint ?? process.env.AURA_ENCODING
320
+ const effectiveHint = explicitHint ?? recallEncoding(displayPath, stat)
321
+ const probe = probeEncoding(bytes, { hint: effectiveHint })
322
+ if (explicitHint !== undefined) rememberEncoding(displayPath, probe.encoding, stat)
323
+ if (probe.encoding === null) {
324
+ // 两个分支分开:含 NUL 的二进制仍说 “binary file”(既有判据靠这句话),
325
+ // 否则是“解不出来”(理论上只剩 UTF-32 之外的怪字节)。
326
+ const isBinary = bytes.includes(0)
327
+ return {
328
+ error: {
329
+ ok: false,
330
+ error: isBinary ? `cannot read "${displayPath}": binary file` : `cannot read "${displayPath}": undecodable text`,
331
+ code: 'FS_NOT_TEXT',
332
+ },
333
+ }
334
+ }
316
335
  try {
317
- return { source: new TextDecoder('utf-8', { fatal: true }).decode(bytes), byteOrderMark }
336
+ return { source: decodeBytes(bytes, probe), byteOrderMark: probe.bom.length > 0, encoding: probe, size: stat.size }
318
337
  } catch (error) {
319
338
  return {
320
339
  error: {
@@ -370,7 +389,7 @@ export async function readEnvelope(provider, args) {
370
389
  )
371
390
  return { ok: true, ...env }
372
391
  }
373
- const got = readFileBytes(displayPath)
392
+ const got = readFileBytes(displayPath, { encoding: args.encoding })
374
393
  if (got.error !== undefined) return got.error
375
394
  const request = normalizeReadRequest(args, got.source.split(/\r?\n/).length)
376
395
  if (request.ok !== true) return request
@@ -387,9 +406,26 @@ export async function readEnvelope(provider, args) {
387
406
  ...(kind === undefined ? {} : { kind }),
388
407
  ...(budget === undefined ? {} : { budget }),
389
408
  byteOrderMark: got.byteOrderMark,
409
+ // probe 用的元信息:文件真实字节数与探测到的编码(DSH 面没有这两个,probe 会降级)。
410
+ fileSize: got.size,
411
+ sourceEncoding: got.encoding?.encoding,
390
412
  filePath: displayPath,
391
413
  })
392
- return { ok: true, ...env }
414
+ /**
415
+ * 编码随回执回去:`encoding` 是探测结果,`encoding_confidence` 只在非 UTF-8 时给——
416
+ * 中/低置信度意味着“读得到但写回会被拒”,调用方要带显式 `encoding` 才能继续。
417
+ */
418
+ const receipt = { ok: true, ...env }
419
+ /**
420
+ * 只给**非 UTF-8**:UTF-8 回执与支持多编码之前逐字节相同(既有判据/金样靠这条等式),
421
+ * 而不是给每一份回执都塞一个 `encoding: "utf-8"`。
422
+ */
423
+ if (got.encoding !== undefined && got.encoding.encoding !== 'utf-8') {
424
+ receipt.encoding = got.encoding.encoding
425
+ receipt.encoding_confidence = got.encoding.confidence
426
+ if (got.encoding.explicit === true) receipt.encoding_source = 'explicit'
427
+ }
428
+ return receipt
393
429
  } catch (error) {
394
430
  /**
395
431
  * C55:非 UTF-8 也走这条 catch(解码确实发生在这里的上游)。单独分一支是必要的: