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.
- package/README.md +3 -3
- package/bin/aura-mcp.mjs +49 -14
- package/bin/aura.mjs +28 -4
- package/lib/encoding-memory.mjs +87 -0
- package/lib/encoding.mjs +410 -0
- package/lib/fs-provider.mjs +60 -6
- package/lib/ops.mjs +50 -14
- package/locator-lab/doc-structure.mjs +382 -0
- package/locator-lab/read-core.mjs +136 -12
- package/locator-lab/write-path.mjs +22 -3
- package/package.json +1 -1
- package/test/doctor-test.mjs +6 -0
- package/test/write-test.mjs +7 -2
package/lib/encoding.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/fs-provider.mjs
CHANGED
|
@@ -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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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:
|
|
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
|
-
|
|
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(解码确实发生在这里的上游)。单独分一支是必要的:
|