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 CHANGED
@@ -1,4 +1,4 @@
1
- # aura-basetools 0.20.0
1
+ # aura-basetools 0.21.0
2
2
 
3
3
  Byte-exact file tools for coding agents: read by intent, edit by address, and write whole files without corrupting bytes. One engine shared by a standalone CLI and an MCP server.
4
4
 
@@ -21,7 +21,7 @@ The engine is intentionally not a general file manager: it reads and edits UTF-8
21
21
  ### Install
22
22
 
23
23
  ```bash
24
- npm i -g aura-basetools@0.20.0
24
+ npm i -g aura-basetools@0.21.0
25
25
  aura --version
26
26
  ```
27
27
 
@@ -137,7 +137,7 @@ MIT.
137
137
  ### 安装
138
138
 
139
139
  ```bash
140
- npm i -g aura-basetools@0.20.0
140
+ npm i -g aura-basetools@0.21.0
141
141
  aura --version
142
142
  ```
143
143
 
package/bin/aura-mcp.mjs CHANGED
@@ -30,8 +30,10 @@ import process from 'node:process'
30
30
  const SERVER_INSTRUCTIONS = [
31
31
  'Prefer the aura_basetools_* tools over built-in Read/Edit/Write for file work when they are available.',
32
32
  'Read with aura_basetools_read; edit with aura_basetools_edit; create or overwrite with aura_basetools_write.',
33
+ 'Keep tool arguments small — long, deeply nested JSON is what the provider\'s stream parser fails on (MALFORMED_RESPONSE). For a single change pass the hunk FLAT at the top level (`old`+`replace`, or `start_line`+`end_line`+`replace`, or a `handle` from a previous read + `replace`); use `hunks: [...]` only for several changes. If you already hold a `handle`, do not repeat the old text in `old`.',
33
34
  'For changes spanning multiple calls use aura_basetools_tx begin -> edit/write with transaction -> commit; rollback on failure.',
34
35
  'These tools preserve BOM and per-line endings, gate only the addressed region for staleness, verify writes, and keep multi-target edits atomic.',
36
+ 'If your client can enforce provider-side strict tool schemas (DeepSeek `/beta` + `strict: true` per function, OpenAI strict), enable it: strict decoding makes malformed tool-call JSON impossible instead of merely rarer. The schemas convert cleanly with `locator-lab/strict-schema.mjs` (toStrictSchema / strictToolList); `aura basetools doctor` prints the recipe.',
35
37
  ].join(' ')
36
38
  import { fileURLToPath, pathToFileURL } from 'node:url'
37
39
  import {
@@ -46,12 +48,15 @@ import {
46
48
  commitTransactionEnvelope,
47
49
  rollbackTransactionEnvelope,
48
50
  statusTransactionEnvelope,
51
+ MODES,
49
52
  assertReadTargets,
50
53
  assertEditTargets,
51
54
  assertTargetShape,
52
55
  assertKnownArgs,
53
56
  MT_READ_ELEMENT_KEYS,
54
57
  MT_EDIT_ELEMENT_KEYS,
58
+ // 平铺形状的拼装规则与 DSH 壳 / CLI 共用这一份(`locator-lab/args.mjs` 的 `flatHunk`)。
59
+ flatHunk,
55
60
  } from '../lib/ops.mjs'
56
61
 
57
62
  // 版本从包自己的 package.json 读:写死过一次,结果 0.4.0 的服务对外还报 0.2.1。
@@ -89,12 +94,19 @@ const TARGETS_SCHEMA = {
89
94
  '一次调用、逐项回执、输出按输入序;一个目标失败不连坐其余(写侧例外:先全部规划,任一规划失败 ⇒ 一个字都不写)。',
90
95
  }
91
96
 
97
+ /**
98
+ * 为什么 `lines` 用 `anyOf` 而不是 `oneOf`:
99
+ * 四个分支按 `type` 互斥,语义完全等价;但 `oneOf` 在部分 provider 的严格模式里不受支持
100
+ * (DeepSeek strict / OpenAI strict 只认 `anyOf`),而 MCP 这一面是**多 agent 共用**的。
101
+ * DSH 适配器保留 `oneOf` —— 它的参数校验器认这个、静默忽略 `anyOf`(见 `aura-edit/index.js`)。
102
+ */
103
+
92
104
  const HUNK_SCHEMA = {
93
105
  type: 'object',
94
106
  properties: {
95
107
  target: { type: 'string', description: '符号名,如 `toTodoList` 或 `function:apply`;跳过全部文本匹配' },
96
108
  lines: {
97
- oneOf: [
109
+ anyOf: [
98
110
  { type: 'array' },
99
111
  { type: 'string' },
100
112
  { type: 'integer' },
@@ -109,7 +121,7 @@ const HUNK_SCHEMA = {
109
121
  old: {
110
122
  type: 'string',
111
123
  description:
112
- '内容地址:你正在替换的**逐字原文**(+ 可选 context 消歧)。给了它,坐标/符号名降为提示;引擎按内容定位,不唯一时拒绝并给候选。',
124
+ '内容地址:你正在替换的**逐字原文**(+ 可选 context 消歧)。给了它,坐标/符号名降为提示;引擎按内容定位,不唯一时拒绝并给候选。手里已有上次回执的 `handle` 时优先用 handle + replace —— 不必把旧文本再抄一遍,参数更短。',
113
125
  },
114
126
  context: { type: 'string', description: '与 old 同用:紧邻被替换区域的原文,只在 old 多处命中时用来消歧。' },
115
127
  /**
@@ -161,19 +173,19 @@ const TOOLS = [
161
173
  {
162
174
  name: 'aura_basetools_read',
163
175
  description:
164
- 'Read a file the way you actually need it. Modes: map (symbol directory), digest (interface only), full, around, lines, search, changed. `auto` picks one for you. Prefer this over reading the whole file.',
176
+ 'Read a file the way you actually need it. Modes: probe (metadata only: lines, bytes, line endings, encoding, language, structure size — no content), map (symbol/section directory), digest (interface only), full (up to the content budget, not necessarily the whole file), around, lines, search, changed. `auto` picks one for you. Prefer this over reading the whole file.',
165
177
  inputSchema: {
166
178
  type: 'object',
167
179
  properties: {
168
180
  file_path: { type: 'string', description: '要读的文件路径(绝对或相对 cwd)' },
169
181
  mode: {
170
182
  type: 'string',
171
- enum: ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed'],
183
+ enum: ['auto', ...MODES],
172
184
  description: '省略时按你给的参数自动判断:行区间→lines、around→around、search→search、since→changed',
173
185
  },
174
186
  around: { type: 'string', description: 'mode=around:以该符号或短语为中心开窗' },
175
187
  lines: {
176
- oneOf: [
188
+ anyOf: [
177
189
  { type: 'array' },
178
190
  { type: 'string' },
179
191
  { type: 'integer' },
@@ -187,6 +199,7 @@ const TOOLS = [
187
199
  context: { type: 'integer', description: 'mode=around:上下各留几行(默认 8)' },
188
200
  kind: { type: 'string', description: 'mode=map:只保留某一类符号(function / class / …)' },
189
201
  budget: { type: 'integer', description: '宿主剪枝阈值(字符);省略用默认 8192' },
202
+ encoding: { type: 'string', description: '源编码覆盖(默认自动探测):gb18030 / shift_jis / euc-kr / big5 / utf-16le / utf-16be / windows-1252。只对本次调用生效;显式指定过一次后会被记住(文件未被外部改动时)。' },
190
203
  since: { type: 'string', description: 'mode=changed:上次回执里的 version 令牌' },
191
204
  max_chars: { type: 'integer' },
192
205
  offset: { type: 'integer', description: '与 limit 同用:0-based 行偏移(内置 Read 的分页形状);与 lines 互斥' },
@@ -205,12 +218,16 @@ const TOOLS = [
205
218
  {
206
219
  name: 'aura_basetools_edit',
207
220
  description:
208
- 'Edit or create a UTF-8 text file with hunks in one call. Address each hunk by target (symbol), lines, line_cue, anchor, or old (verbatim content + optional context). With `old`, set `replace_all: true` to replace every non-overlapping occurrence. Writes back byte-for-byte (BOM and per-line endings preserved), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify. All hunks must locate or nothing is written.',
221
+ 'Edit or create a text file in one call. PREFER THE FLAT FORM for a single change: put the hunk fields at the top level (`old`+`replace`, or `start_line`/`end_line`+`replace`, or `handle` from a previous read + `replace`) and omit `hunks` entirely — one JSON object instead of an array of objects, which is smaller and survives the provider\'s stream parser more reliably. Use `hunks: [...]` only when a call changes several places. Address each hunk by target (symbol), lines, line_cue, anchor, handle, or old (verbatim content + optional context). With `old`, set `replace_all: true` to replace every non-overlapping occurrence. Writes back byte-for-byte (BOM and per-line endings preserved), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify. All hunks must locate or nothing is written. A single hunk may also be passed flat at the top level (`file_path` + the hunk fields) instead of wrapped in `hunks: [...]`. JSON/JSONC edits: prefer `lines`/`start_line`+`end_line` and put only the new text in `replace` — re-typing the old text doubles the escaped quotes/newlines the call must carry.',
209
222
  inputSchema: {
210
223
  type: 'object',
211
224
  properties: {
212
225
  file_path: { type: 'string' },
213
- hunks: { type: 'array', items: HUNK_SCHEMA },
226
+ hunks: {
227
+ type: 'array',
228
+ items: HUNK_SCHEMA,
229
+ description: '多次修改时用;**单处修改请用平铺形式**(把 hunk 字段直接放顶层、不传 hunks):一个对象比“对象数组”少一层嵌套,参数更短、更不容易在流式解码时坏掉。',
230
+ },
214
231
  dry_run: { type: 'boolean', description: '只定位不落盘' },
215
232
  require_observation: {
216
233
  type: 'boolean',
@@ -220,9 +237,18 @@ const TOOLS = [
220
237
  type: 'string',
221
238
  description: '写后验证命令(显式给出才执行;在 workspace 根下、受超时/输出上限约束)。非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED。不自动发现/执行仓库脚本。',
222
239
  },
223
- transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务,不立即 commit' },
240
+ transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务的回滚日志。**写入立即可见**(tx 不是隔离事务,pending 期间盘上已是新字节),commit 只做 afterVersion 校验与可选 verify' },
224
241
  create: { type: 'string', description: '文件不存在时用它创建(整份内容)' },
242
+ encoding: { type: 'string', description: '源编码覆盖(默认自动探测);猜不准时写会拒绝,用它显式指定。只对本次调用生效。' },
225
243
  targets: TARGETS_SCHEMA,
244
+ /**
245
+ * 平铺形状:单个 hunk 的键**也**声明在顶层(与 DSH 壳、CLI 同一份容错)。
246
+ *
247
+ * 此前只有 DSH 壳声明了这些键:同一份载荷 `{file_path, start_line, end_line, replace}`
248
+ * 在 DSH 面被执行、在 MCP 面被未知参数守卫拒掉——而 MCP 正是 Codex/Claude Code/Cursor/ZCode
249
+ * 共用的那一面。声明与实装现在同源(拼装仍走 `flatHunk`,不另写一份判据)。
250
+ */
251
+ ...HUNK_SCHEMA.properties,
226
252
  },
227
253
  /**
228
254
  * `hunks` **不在 required 里**:`create` 就是在建一个新文件,那时没有 hunk 可给。
@@ -247,6 +273,7 @@ const TOOLS = [
247
273
  properties: {
248
274
  file_path: { type: 'string', description: '要写的文件路径(绝对或相对 cwd);不存在则创建' },
249
275
  content: { type: 'string', description: '整份内容;覆盖已有文件时替换全部行' },
276
+ encoding: { type: 'string', description: '源编码覆盖(默认自动探测);写回沿用该编码。只对本次调用生效。' },
250
277
  dry_run: { type: 'boolean', description: '只规划不落盘:新建报 create,覆盖校验换行与字节数' },
251
278
  no_overwrite: { type: 'boolean', description: '文件已存在时拒绝(WRITE_EXISTS)' },
252
279
  allow_unobserved: {
@@ -260,9 +287,9 @@ const TOOLS = [
260
287
  },
261
288
  verify: {
262
289
  type: 'string',
263
- description: '写后验证命令(显式给出才执行;非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED)。',
290
+ description: '写后验证命令(显式给出才执行;在**工作区根目录**执行;无超时参数——MCP 传输上限约 30s,慢命令不要放进来;非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED)。',
264
291
  },
265
- transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务,不立即 commit' },
292
+ transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务的回滚日志。**写入立即可见**(tx 不是隔离事务),commit 只做 afterVersion 校验与可选 verify' },
266
293
  },
267
294
  required: ['file_path', 'content'],
268
295
  },
@@ -270,13 +297,13 @@ const TOOLS = [
270
297
  {
271
298
  name: 'aura_basetools_tx',
272
299
  description:
273
- 'Begin, commit, rollback, or inspect a cross-call transaction. Begin returns an id; pass it to edit/write `transaction` to append changes; commit checks each written file was not changed externally and can run `verify`; rollback restores all files.',
300
+ 'Begin, commit, rollback, or inspect a cross-call transaction. Begin returns an id; pass it to edit/write `transaction` to append changes. Writes are visible immediately: a transaction is a cross-file ROLLBACK JOURNAL, not isolation — while it is pending the file on disk already holds the new bytes. `commit` checks each written file was not changed externally and can run `verify`; `rollback` restores all files.',
274
301
  inputSchema: {
275
302
  type: 'object',
276
303
  properties: {
277
304
  action: { type: 'string', enum: ['begin', 'commit', 'rollback', 'status'] },
278
305
  transaction: { type: 'string', description: 'tx begin 返回的事务 id(begin 时不需要)' },
279
- verify: { type: 'string', description: 'commit 时的写后验证命令;失败 ⇒ 整个事务回滚' },
306
+ verify: { type: 'string', description: 'commit 时的写后验证命令(在**工作区根目录**执行、无超时参数;MCP 传输上限约 30s);失败 ⇒ 整个事务回滚' },
280
307
  },
281
308
  required: ['action'],
282
309
  },
@@ -416,6 +443,11 @@ async function callTool(provider, name, args = {}) {
416
443
  }
417
444
  }
418
445
  }
446
+ /**
447
+ * 逐调用编码覆盖(0.21):长驻进程里 provider 是同一个,环境变量没法按调用改。
448
+ * 不传时复位到环境变量——不能把上一次调用的 hint 泄漏给下一次。
449
+ */
450
+ if (typeof provider.setEncodingHint === 'function') provider.setEncodingHint(args.encoding)
419
451
  if (name === 'aura_basetools_tx') {
420
452
  const action = args.action
421
453
  if (action === 'begin') return beginTransactionEnvelope(provider)
@@ -445,7 +477,10 @@ async function callTool(provider, name, args = {}) {
445
477
  * 多目标形状不走这一条:每个 target 自带 hunks/create(契约 §2.2)。
446
478
  */
447
479
  const creating = typeof args.create === 'string'
448
- if (!Array.isArray(args.hunks) && !creating) {
480
+ // 平铺形状(单 hunk 直接写在顶层):拼装规则只认 `flatHunk` 那一份,不在 bin 里另写。
481
+ const flat = Array.isArray(args.hunks) ? null : flatHunk(args)
482
+ const hunks = Array.isArray(args.hunks) ? args.hunks : flat === null ? [] : [flat]
483
+ if (hunks.length === 0 && !creating) {
449
484
  return {
450
485
  ok: false,
451
486
  error: 'aura_basetools_edit 需要 hunks 数组,或 create(整份内容建文件)二选一;只建文件时直接给 create 即可',
@@ -454,7 +489,7 @@ async function callTool(provider, name, args = {}) {
454
489
  }
455
490
  return await editEnvelope(provider, {
456
491
  file: args.file_path,
457
- hunks: Array.isArray(args.hunks) ? args.hunks : [],
492
+ hunks,
458
493
  dryRun: args.dry_run === true,
459
494
  create: creating ? args.create : undefined,
460
495
  ...(args.require_observation === true ? { requireObservation: true } : {}),
package/bin/aura.mjs CHANGED
@@ -87,15 +87,15 @@ const isBooleanFlag = (token) => typeof token === 'string' && BOOLEAN_FLAGS.has(
87
87
  */
88
88
  const COMMAND_FLAGS = {
89
89
  read: {
90
- value: ['cwd', 'file', 'targets', 'mode', 'around', 'lines', 'start-line', 'end-line', 'search', 'since', 'context', 'kind', 'budget', 'max-chars', 'offset', 'limit'],
90
+ value: ['cwd', 'file', 'targets', 'mode', 'around', 'lines', 'start-line', 'end-line', 'search', 'since', 'context', 'kind', 'budget', 'max-chars', 'offset', 'limit', 'encoding'],
91
91
  bool: ['pretty'],
92
92
  },
93
93
  edit: {
94
- value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol', 'verify', 'tx'],
94
+ value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol', 'verify', 'tx', 'encoding'],
95
95
  bool: ['pretty', 'dry-run', 'plan', 'replace-all', 'require-observation'],
96
96
  },
97
97
  write: {
98
- value: ['cwd', 'file', 'content', 'eol', 'verify', 'tx'],
98
+ value: ['cwd', 'file', 'content', 'eol', 'verify', 'tx', 'encoding'],
99
99
  bool: ['pretty', 'dry-run', 'plan', 'no-overwrite', 'allow-unobserved'],
100
100
  },
101
101
  tx: { value: ['cwd', 'id', 'verify'], bool: ['pretty', 'json'] },
@@ -339,7 +339,8 @@ if (command === 'mcp') {
339
339
  process.exit(0)
340
340
  }
341
341
 
342
- const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd())
342
+ const encodingFlag = typeof flag('encoding') === 'string' && flag('encoding').length > 0 ? flag('encoding') : undefined
343
+ const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd(), { encoding: encodingFlag })
343
344
 
344
345
  if (command === 'tx') {
345
346
  const action = argv[1]
@@ -441,11 +442,29 @@ if (command === 'doctor') {
441
442
  probeWritable(cacheDir, 'cache-writable')
442
443
  probeWritable(path.join(cacheDir, 'locks'), 'lock-writable')
443
444
  const ok = checks.every((check) => check.ok)
445
+ /**
446
+ * strict 提示(不是检查项):让“能不能把参数解析失败归零”这件事在装完就能看到。
447
+ *
448
+ * 工具侧不能替宿主开 strict(那是 provider/客户端配置),但能说清楚“我们的 schema 转得过去”
449
+ * 与“怎么开”。转换器导出在 `locator-lab/strict-schema.mjs`(`toStrictSchema` / `strictToolList`),
450
+ * 已随包发布(package.json 的 `./locator-lab/*`)。
451
+ *
452
+ * 为什么放在 doctor 而不是新开一个命令:doctor 是“装完跑一下”的那条路径,
453
+ * 而 strict 能不能开决定了“参数解析失败”是否还会中断任务。
454
+ */
455
+ const strict = {
456
+ compatible: true,
457
+ convert: 'aura-basetools/locator-lab/strict-schema.mjs',
458
+ deepseek: 'base_url=https://api.deepseek.com/beta + "strict": true on every function (server validates the schema)',
459
+ openai: 'strict: true (all properties required + additionalProperties:false)',
460
+ note: 'strict 让模型不可能输出非法 JSON;不开时工具侧只能降低概率(P1 发生在工具被调用之前)',
461
+ }
444
462
  const report = {
445
463
  ok,
446
464
  version: VERSION,
447
465
  node: process.versions.node,
448
466
  checks,
467
+ strict,
449
468
  ...(process.env.AURA_NO_LOCK === '1' ? { notes: ['AURA_NO_LOCK=1: workspace lock is disabled'] } : {}),
450
469
  }
451
470
  if (has('json')) {
@@ -454,6 +473,10 @@ if (command === 'doctor') {
454
473
  }
455
474
  process.stdout.write(`aura basetools doctor: ${ok ? 'ok' : 'FAILED'} (${VERSION}, node ${process.versions.node})\n`)
456
475
  for (const check of checks) process.stdout.write(` ${check.ok ? '✓' : '✗'} ${check.id}: ${check.detail}\n`)
476
+ process.stdout.write(` • strict: schemas 可转成 provider strict 变体(全 required + nullable + anyOf)——\n`)
477
+ process.stdout.write(` DeepSeek: ${strict.deepseek}\n`)
478
+ process.stdout.write(` OpenAI: ${strict.openai}\n`)
479
+ process.stdout.write(` 转换器: ${strict.convert}|不开 strict 时“参数不是合法 JSON”仍会中断任务(工具看不到那段字节)\n`)
457
480
  process.exit(ok ? 0 : 2)
458
481
  }
459
482
 
@@ -493,6 +516,7 @@ if (command === 'read' || command === 'outline') {
493
516
  limit: typeof flag('limit') === 'string' ? Number(flag('limit')) : undefined,
494
517
  // 光写 `--max-chars` 不带值时 flag() 回 true,Number(true)=1 会变成"预算 1 字符"这种怪值
495
518
  maxChars: typeof flag('max-chars') === 'string' ? Number(flag('max-chars')) : undefined,
519
+ encoding: encodingFlag,
496
520
  })
497
521
  emit(env, env.ok ? 0 : 2)
498
522
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * encoding-memory.mjs —— 记住“这个文件是什么编码”,设一次就够。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 探测在中/低置信度时会**拒绝写**(`ENCODING_AMBIGUOUS`)——这是对的:写错编码等于写坏字节。
7
+ * 但用户每次都要 `AURA_ENCODING=gb18030` 才能改同一个文件,体验很差。
8
+ *
9
+ * 所以:**显式指定过一次,就记住**。下次同一文件(大小与 mtime 都没变)自动沿用,
10
+ * 不需要再设环境变量。
11
+ *
12
+ * ## 为什么不无条件信任记忆
13
+ *
14
+ * 文件被别的工具重写后,编码可能变了(甚至从 GBK 改成 UTF-8)。所以记忆条目带
15
+ * `{size, mtimeMs}` 指纹:**对不上就忽略**,回到探测。宁可再问一次,不可写错。
16
+ *
17
+ * 存放位置:`AURA_CACHE_DIR`(默认 `~/.aura`)下的 `encodings.json`;目录不可写时静默跳过
18
+ * ——记忆是加速器,不是正确性依赖。
19
+ */
20
+ import fs from 'node:fs'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+
24
+ const MAX_ENTRIES = 500
25
+
26
+ function storePath() {
27
+ const dir = process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
28
+ return path.join(dir, 'encodings.json')
29
+ }
30
+
31
+ function readAll() {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(storePath(), 'utf8'))
34
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
35
+ } catch {
36
+ return {}
37
+ }
38
+ }
39
+
40
+ function writeAll(map) {
41
+ try {
42
+ const file = storePath()
43
+ fs.mkdirSync(path.dirname(file), { recursive: true })
44
+ const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.tmp`
45
+ fs.writeFileSync(tmp, JSON.stringify(map))
46
+ fs.renameSync(tmp, file)
47
+ } catch {
48
+ /* 记忆是加速器,不是正确性依赖 */
49
+ }
50
+ }
51
+
52
+ /** 指纹:大小 + mtime;任一缺失就当作“不知道”,不冒险复用。 */
53
+ function stampOf(stat) {
54
+ if (stat === undefined || stat === null) return null
55
+ const size = Number(stat.size)
56
+ const mtimeMs = Number(stat.mtimeMs)
57
+ if (!Number.isFinite(size) || !Number.isFinite(mtimeMs)) return null
58
+ return { size, mtimeMs: Math.trunc(mtimeMs) }
59
+ }
60
+
61
+ /** 记住一个文件的编码(UTF-8 不记:它是默认值,记住只会让表变脏)。 */
62
+ export function rememberEncoding(displayPath, encoding, stat) {
63
+ if (typeof displayPath !== 'string' || typeof encoding !== 'string') return
64
+ if (encoding === 'utf-8') return
65
+ const stamp = stampOf(stat)
66
+ if (stamp === null) return
67
+ const all = readAll()
68
+ all[path.resolve(displayPath)] = { encoding, ...stamp }
69
+ // 简单的 LRU:超上限时按 mtime 丢掉最旧的一半。
70
+ const keys = Object.keys(all)
71
+ if (keys.length > MAX_ENTRIES) {
72
+ keys.sort((a, b) => (all[a].mtimeMs ?? 0) - (all[b].mtimeMs ?? 0))
73
+ for (const key of keys.slice(0, keys.length - MAX_ENTRIES)) delete all[key]
74
+ }
75
+ writeAll(all)
76
+ }
77
+
78
+ /** 取回记忆的编码;指纹对不上返回 undefined。 */
79
+ export function recallEncoding(displayPath, stat) {
80
+ if (typeof displayPath !== 'string') return undefined
81
+ const entry = readAll()[path.resolve(displayPath)]
82
+ if (entry === undefined || typeof entry.encoding !== 'string') return undefined
83
+ const stamp = stampOf(stat)
84
+ if (stamp === null) return undefined
85
+ if (entry.size !== stamp.size || entry.mtimeMs !== stamp.mtimeMs) return undefined
86
+ return entry.encoding
87
+ }