aura-basetools 0.0.0-stage → 0.7.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,355 @@
1
+ /**
2
+ * fs-provider.mjs —— 给独立 CLI / MCP 用的**本机文件提供方**(node:fs,零依赖,跨平台)。
3
+ *
4
+ * 引擎(`locator-lab/write-path.mjs`)只认一个四方法契约,不认框架:
5
+ * stat(target) -> { version, size } | undefined
6
+ * readText(target) -> string 提供方视角(解码时剥掉恰好一个前导 BOM)
7
+ * readByteRange(target, { offset, length }) -> Uint8Array
8
+ * writeText(target, content, expected) -> { version }
9
+ *
10
+ * 与 DSH 那边 `dsh-fs-local` 的语义逐条对齐(这样两条路上的判据才可比):
11
+ * · 解码用 `new TextDecoder('utf-8', { fatal: true })` —— 默认 `ignoreBOM:false`,**第一个 BOM 不进字符串**;
12
+ * · 写入用 `TextEncoder`,**逐字节**,不做行尾归一(DSH 那边踩过的坑:`editText` 会按多数派归一);
13
+ * · 落盘走**临时文件 + rename**(同目录),Windows 上 rename 也覆盖已存在文件;
14
+ * · `expected.kind === 'replaceIfVersion'` 时先比版本,不符抛 `FS_STALE_VERSION`(本机版 CAS);
15
+ * · `expected.kind === 'createIfAbsent'` 时文件已存在则抛 `FS_NOT_OBSERVED`。
16
+ *
17
+ * 版本指纹 = `size:mtimeMs:sha256(前 4KB + 后 4KB + size)`:
18
+ * 只读头尾,不整份读;但足以发现常见的内容改写。
19
+ *
20
+ * 写入边界(v0.6.19 起):
21
+ * · 现有文件的 mode 在替换后保留(POSIX;Windows 只保留可写位语义);
22
+ * · 符号链接**写穿**到 realpath,链接本身保留;悬空链接拒绝;
23
+ * · 硬链接(nlink > 1)拒绝,因为 rename 会打断另一个名字;
24
+ * · 文本读取/写入有大小上限(默认 512 MiB,可由 `AURA_MAX_FILE_BYTES` 覆盖);
25
+ * · `readByteRange` 只读请求区间,不再整份读。
26
+ */
27
+ import fs from 'node:fs'
28
+ import path from 'node:path'
29
+ import crypto from 'node:crypto'
30
+
31
+ const SAMPLE = 4096
32
+ /** 小文件走全文哈希;大文件才退回头尾采样。 */
33
+ const FULL_HASH_LIMIT = 16 * 1024 * 1024
34
+ export const DEFAULT_MAX_TEXT_BYTES = 512 * 1024 * 1024
35
+
36
+ /** 读文件(返回 Buffer);文件不存在 / 不是普通文件 / 超限抛带 code 的错误。 */
37
+ function readOrThrow(target, maxBytes) {
38
+ let st
39
+ try {
40
+ st = fs.statSync(target)
41
+ } catch (error) {
42
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot read "${target}": not found`), { code: 'FS_NOT_FOUND' })
43
+ throw error
44
+ }
45
+ if (st.isDirectory()) throw Object.assign(new Error(`cannot read "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
46
+ if (!st.isFile()) throw Object.assign(new Error(`cannot read "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
47
+ if (st.size > maxBytes) {
48
+ throw Object.assign(
49
+ new Error(`cannot read "${target}": file is ${st.size} bytes, over the ${maxBytes}-byte text limit`),
50
+ { code: 'FS_TOO_LARGE', size: st.size, limit: maxBytes },
51
+ )
52
+ }
53
+ try {
54
+ return fs.readFileSync(target)
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot read "${target}": not found`), { code: 'FS_NOT_FOUND' })
57
+ throw error
58
+ }
59
+ }
60
+
61
+ /** 版本指纹:size + mtimeMs + 头尾采样哈希。 */
62
+ export function fingerprintOf(buffer, stat) {
63
+ const hash = crypto.createHash('sha256')
64
+ if (buffer.length <= FULL_HASH_LIMIT) {
65
+ // 小文件全文哈希:中间改写即使 mtime/ctime 都没变也能被发现。
66
+ hash.update(buffer)
67
+ } else {
68
+ const head = buffer.subarray(0, SAMPLE)
69
+ const tail = buffer.length > SAMPLE ? buffer.subarray(buffer.length - SAMPLE) : Buffer.alloc(0)
70
+ hash.update(head).update(tail).update(String(buffer.length))
71
+ }
72
+ const digest = hash.digest('hex').slice(0, 16)
73
+ // ctime 不能由普通写者回拨;把它纳入版本,能抓住“改中间 + 恢复 mtime”的改写。
74
+ // 优先用 ctimeNs:Linux 上写入与 utimes 可能落在同一毫秒,ctimeMs 截断后看起来没变。
75
+ const ctime = stat.ctimeNs !== undefined ? stat.ctimeNs.toString() : String(Math.trunc(stat.ctimeMs ?? 0))
76
+ return `${buffer.length}:${Math.trunc(stat.mtimeMs)}:${ctime}:${digest}`
77
+ }
78
+
79
+ /** 只读一段;短读时返回实际读到的字节,不补零。 */
80
+ function readExact(fd, length, position) {
81
+ if (length <= 0) return Buffer.alloc(0)
82
+ const buffer = Buffer.alloc(length)
83
+ let got = 0
84
+ while (got < length) {
85
+ const n = fs.readSync(fd, buffer, got, length - got, position + got)
86
+ if (n === 0) break
87
+ got += n
88
+ }
89
+ return got === length ? buffer : buffer.subarray(0, got)
90
+ }
91
+
92
+ /** 头尾采样指纹:不再为了 fingerprintOf 把整份文件读进内存。 */
93
+ function fingerprintFile(file, stat) {
94
+ let fd
95
+ try {
96
+ fd = fs.openSync(file, 'r')
97
+ if (stat.size <= FULL_HASH_LIMIT) {
98
+ return fingerprintOf(fs.readFileSync(file), stat)
99
+ }
100
+ const head = readExact(fd, Math.min(SAMPLE, stat.size), 0)
101
+ const tailLength = stat.size > SAMPLE ? Math.min(SAMPLE, stat.size - SAMPLE) : 0
102
+ const tail = tailLength > 0 ? readExact(fd, tailLength, stat.size - tailLength) : Buffer.alloc(0)
103
+ const hash = crypto.createHash('sha256').update(head).update(tail).update(String(stat.size)).digest('hex').slice(0, 16)
104
+ const ctime = stat.ctimeNs !== undefined ? stat.ctimeNs.toString() : String(Math.trunc(stat.ctimeMs ?? 0))
105
+ return `${stat.size}:${Math.trunc(stat.mtimeMs)}:${ctime}:${hash}`
106
+ } finally {
107
+ if (fd !== undefined) {
108
+ try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 原子写:同目录临时文件 + fsync + rename(跨平台)。
115
+ *
116
+ * `mode` 非空时在 POSIX 上把临时文件 chmod 回原 mode;Windows 的 mode 只承载可写位,
117
+ * 不做 chmod,避免把只读位带上临时文件后 rename 失败。ACL / xattr 不在本实现的保全范围。
118
+ */
119
+ function writeAtomic(target, content, { mode = null, beforeRename = null } = {}) {
120
+ const dir = path.dirname(target)
121
+ const tmp = path.join(dir, `.${path.basename(target)}.aura-${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}.tmp`)
122
+ let fd
123
+ try {
124
+ fd = fs.openSync(tmp, 'wx', mode ?? 0o666)
125
+ const buffer = Buffer.from(content)
126
+ let offset = 0
127
+ while (offset < buffer.length) {
128
+ offset += fs.writeSync(fd, buffer, offset, buffer.length - offset)
129
+ }
130
+ // 数据落盘后再 rename;不支持的 FS 上 fsync 失败不应把一个已经写好的临时文件变成脏状态。
131
+ try { fs.fsyncSync(fd) } catch { /* 尽力而为 */ }
132
+ fs.closeSync(fd)
133
+ fd = undefined
134
+ // 最后一道 CAS:临时文件已经写好,rename 前再确认一次目标版本。
135
+ // 它把“检查 → rename”的窗口压到最小;不是跨进程锁,但能挡住绝大多数并发写者。
136
+ if (beforeRename !== null) beforeRename()
137
+ if (mode !== null && path.sep !== '\\') fs.chmodSync(tmp, mode)
138
+ fs.renameSync(tmp, target)
139
+ // 目录项也尽量落盘;失败不影响已经完成的 rename。
140
+ try {
141
+ const dirFd = fs.openSync(dir, 'r')
142
+ try { fs.fsyncSync(dirFd) } catch { /* 尽力而为 */ }
143
+ fs.closeSync(dirFd)
144
+ } catch { /* 尽力而为 */ }
145
+ } catch (error) {
146
+ if (fd !== undefined) {
147
+ try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
148
+ }
149
+ try { fs.rmSync(tmp, { force: true }) } catch { /* 清理失败不影响正确性 */ }
150
+ throw error
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 写目标解析:符号链接写穿到 realpath;硬链接拒绝。
156
+ *
157
+ * 为什么硬链接要拒绝:原子替换是“换一个 inode”,另一个硬链接仍指向旧 inode。
158
+ * 这会让两个名字在写入后看到不同内容——对一个声称逐字节保真的编辑器来说,静默发生比拒绝更坏。
159
+ */
160
+ function resolveWriteTarget(target) {
161
+ let lst
162
+ try {
163
+ lst = fs.lstatSync(target)
164
+ } catch (error) {
165
+ if (error?.code === 'ENOENT') return { file: target, exists: false, stat: null }
166
+ throw error
167
+ }
168
+ if (lst.isSymbolicLink()) {
169
+ let real
170
+ try {
171
+ real = fs.realpathSync.native(target)
172
+ } catch {
173
+ throw Object.assign(new Error(`cannot patch "${target}": dangling symbolic link`), { code: 'FS_SYMLINK_BROKEN' })
174
+ }
175
+ let st
176
+ try {
177
+ st = fs.statSync(real)
178
+ } catch {
179
+ throw Object.assign(new Error(`cannot patch "${target}": symbolic link target is not readable`), { code: 'FS_SYMLINK_BROKEN' })
180
+ }
181
+ if (!st.isFile()) throw Object.assign(new Error(`cannot patch "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
182
+ if (st.nlink > 1) {
183
+ throw Object.assign(
184
+ new Error(`cannot patch "${target}": file has ${st.nlink} hard links; atomic replace would break the other names`),
185
+ { code: 'FS_HARDLINK_UNSUPPORTED', links: st.nlink },
186
+ )
187
+ }
188
+ return { file: real, exists: true, stat: st }
189
+ }
190
+ if (!lst.isFile()) throw Object.assign(new Error(`cannot patch "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
191
+ if (lst.nlink > 1) {
192
+ throw Object.assign(
193
+ new Error(`cannot patch "${target}": file has ${lst.nlink} hard links; atomic replace would break the other names`),
194
+ { code: 'FS_HARDLINK_UNSUPPORTED', links: lst.nlink },
195
+ )
196
+ }
197
+ return { file: target, exists: true, stat: lst }
198
+ }
199
+
200
+ /**
201
+ * 造一个提供方。
202
+ * @param options.resolveRoot - 相对路径的解析根(默认进程 cwd);对象里只留绝对路径,避免"被检查的路径"与"被写的路径"不是同一个。
203
+ * @param options.maxTextBytes - 单次文本读/写的上限;默认读 `AURA_MAX_FILE_BYTES`,否则 512 MiB。
204
+ */
205
+ export function createNodeFsProvider(options = {}) {
206
+ const root = path.resolve(options.resolveRoot ?? process.cwd())
207
+ const abs = (p) => (path.isAbsolute(p) ? path.normalize(p) : path.resolve(root, p))
208
+ const configuredMax = options.maxTextBytes ?? Number(process.env.AURA_MAX_FILE_BYTES)
209
+ const maxTextBytes = Number.isSafeInteger(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_TEXT_BYTES
210
+
211
+ const stat = (target) => {
212
+ const file = abs(target)
213
+ let st
214
+ try {
215
+ st = fs.statSync(file)
216
+ } catch {
217
+ return undefined
218
+ }
219
+ /**
220
+ * 目录**不再抛 `FS_NOT_REGULAR_FILE`**,而是带上 `type` 返回。
221
+ *
222
+ * 为什么改:仓库级地图(T26)需要“这个路径是目录”这件事先被知道。
223
+ * 旧行为下 `aura read <目录>` 报的是 `cannot patch "...": not a regular file`——
224
+ * 一句话里有两个错:动作是读不是改,而真正的原因(它是个目录)根本没提。
225
+ * 类型字段与 `dsh-fs-local` 的 `probe()` 同形(`file` / `directory` / `other`),
226
+ * 两边判据因此可比。
227
+ */
228
+ if (st.isDirectory()) return { type: 'directory', size: 0 }
229
+ if (!st.isFile()) throw Object.assign(new Error(`cannot patch "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
230
+ return { type: 'file', version: fingerprintFile(file, st), size: st.size }
231
+ }
232
+
233
+ return {
234
+ /** 工作区根(事务日志/锁的键;DSH 侧不使用本提供方)。 */
235
+ root,
236
+ /** 解析成绝对路径(CLI 的显示路径 → 真实路径的唯一次转换)。 */
237
+ resolve: (p) => abs(p),
238
+ stat,
239
+ 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)
244
+ },
245
+ async readByteRange(target, range) {
246
+ const file = abs(target)
247
+ let st
248
+ try {
249
+ st = fs.statSync(file)
250
+ } catch (error) {
251
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot read "${target}": not found`), { code: 'FS_NOT_FOUND' })
252
+ throw error
253
+ }
254
+ if (!st.isFile()) throw Object.assign(new Error(`cannot read "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
255
+ const offset = Math.min(st.size, Math.max(0, range?.offset ?? 0))
256
+ const length = Math.min(Math.max(0, range?.length ?? 0), st.size - offset)
257
+ if (length === 0) return new Uint8Array(0)
258
+ let fd
259
+ try {
260
+ fd = fs.openSync(file, 'r')
261
+ return new Uint8Array(readExact(fd, length, offset))
262
+ } finally {
263
+ if (fd !== undefined) {
264
+ try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
265
+ }
266
+ }
267
+ },
268
+ /**
269
+ * 列直接子项(目录级地图用,T26)。
270
+ *
271
+ * 形状与 `dsh-fs-local.listDir` 对齐而非自创:`{ name, type, size, target }`,
272
+ * `type` ∈ `directory` / `file` / `other`。
273
+ * 两边的地图因此可以由同一份引擎逻辑驱动(`collectRepoMap`),
274
+ * 不会出现“CLI 列目录的规则与 DSH 不一样”这类只有对比才能发现的偏差。
275
+ * `target` 用**绝对路径字符串**:本提供方的其余方法都接受绝对路径,不必再包一层。
276
+ *
277
+ * @param target - 目录(绝对或相对解析根)
278
+ * @returns `{name, type, size, target}[]`,按名字排序(可重现的回执)
279
+ */
280
+ async listDir(target) {
281
+ const dir = abs(target)
282
+ let entries
283
+ try {
284
+ entries = fs.readdirSync(dir, { withFileTypes: true })
285
+ } catch (error) {
286
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot list "${target}": not found`), { code: 'FS_NOT_FOUND' })
287
+ throw error
288
+ }
289
+ return entries
290
+ .sort((a, b) => a.name.localeCompare(b.name))
291
+ .map((e) => {
292
+ const child = path.join(dir, e.name)
293
+ let size
294
+ if (e.isFile()) {
295
+ try {
296
+ size = fs.statSync(child).size
297
+ } catch {
298
+ size = 0
299
+ }
300
+ }
301
+ return {
302
+ name: e.name,
303
+ type: e.isDirectory() ? 'directory' : e.isFile() ? 'file' : 'other',
304
+ ...(size === undefined ? {} : { size }),
305
+ target: child,
306
+ }
307
+ })
308
+ },
309
+ async writeText(target, content, expected) {
310
+ const display = target
311
+ const bytes = Buffer.byteLength(content)
312
+ if (bytes > maxTextBytes) {
313
+ throw Object.assign(
314
+ new Error(`cannot patch "${display}": content is ${bytes} bytes, over the ${maxTextBytes}-byte text limit`),
315
+ { code: 'FS_TOO_LARGE', size: bytes, limit: maxTextBytes },
316
+ )
317
+ }
318
+ const resolved = resolveWriteTarget(abs(target))
319
+ if (expected?.kind === 'createIfAbsent' && resolved.exists) {
320
+ throw Object.assign(new Error(`cannot overwrite existing "${display}" without reading it first`), { code: 'FS_NOT_OBSERVED' })
321
+ }
322
+ if (expected?.kind === 'replaceIfVersion') {
323
+ if (!resolved.exists) throw Object.assign(new Error(`cannot patch "${display}": file no longer exists`), { code: 'FS_STALE_VERSION' })
324
+ const current = fingerprintFile(resolved.file, resolved.stat)
325
+ if (current !== expected.version) {
326
+ throw Object.assign(new Error(`cannot patch "${display}": file changed since it was read`), { code: 'FS_STALE_VERSION' })
327
+ }
328
+ }
329
+ let beforeRename = null
330
+ if (expected?.kind === 'replaceIfVersion') {
331
+ beforeRename = () => {
332
+ let st
333
+ try { st = fs.statSync(resolved.file) } catch {
334
+ throw Object.assign(new Error(`cannot patch "${display}": file no longer exists`), { code: 'FS_STALE_VERSION' })
335
+ }
336
+ if (fingerprintFile(resolved.file, st) !== expected.version) {
337
+ throw Object.assign(new Error(`cannot patch "${display}": file changed while the patch was being written`), { code: 'FS_STALE_VERSION' })
338
+ }
339
+ }
340
+ } else if (expected?.kind === 'createIfAbsent') {
341
+ beforeRename = () => {
342
+ if (fs.existsSync(resolved.file)) {
343
+ throw Object.assign(new Error(`cannot overwrite existing "${display}" without reading it first`), { code: 'FS_NOT_OBSERVED' })
344
+ }
345
+ }
346
+ }
347
+ writeAtomic(resolved.file, content, {
348
+ mode: resolved.exists ? resolved.stat.mode & 0o7777 : null,
349
+ beforeRename,
350
+ })
351
+ const after = fs.statSync(resolved.file)
352
+ return { version: fingerprintFile(resolved.file, after) }
353
+ },
354
+ }
355
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * observation-store.mjs —— 观测缓存的**落盘实现**(给独立 CLI 用)。
3
+ *
4
+ * 引擎默认用进程内 Map(`locator-lab/read-core.mjs` 的 `createMemoryStore`)。DSH 壳是长进程,
5
+ * 够用;但独立 CLI **每次调用都是新进程**,上一轮的观测必须活到进程外——否则区域级新鲜度闸门
6
+ * ("你读之后这块区域被人动过",C13)在 CLI 上永远拿不到基准,等于没有。
7
+ *
8
+ * 落在 `~/.aura/observations.json`(macOS / Linux / Windows 同形,用 `os.homedir()`)。
9
+ * 有界:最多 `LIMIT` 条;**超大文件不入缓存**(不为了缓存把内存/硬盘吃掉)——不存就是"没观测过",
10
+ * 闸门放行并在回执里带 `unverified_region`,语义与淘汰一致。
11
+ *
12
+ * ── 键必须是**同一个磁盘文件**的键,不是**同一个路径字符串**的键(C56)─────────────
13
+ *
14
+ * C54 把账本的路径同一性做进了引擎:纯词法归一(`.` 段/冗余分隔符/正反斜线/盘符大小写)
15
+ * 加一个**会话内**的折叠别名索引。DSH 壳是长进程,那条路有效;**独立面每次调用都是新进程**,
16
+ * 别名索引随进程消失 ⇒ 剩下的洞是:
17
+ *
18
+ * 进程1 `aura read --file <tmp>/Beta.js` (建立观测,写进 observations.json)
19
+ * 第三方 改掉**被寻址的那一行**
20
+ * 进程2 `aura edit --file <tmp>/beta.js` (无 `expect`)
21
+ * ⇒ 改前实测:`{"ok":true,…, "unverified_region":true}` —— **静默覆盖**(点斜线写法在同实验里被拒)。
22
+ *
23
+ * `unverified_region: true` 是诚实的标注,但它的代价是那一行**没有闸门**:写侧的版本 CAS 在
24
+ * 一次调用**内部**取 `stat`,调用之前发生的第三方写入对它是"当前状态",不是冲突 —— 这个洞一旦
25
+ * 被穿过,"模型手上的行号来自更早的一版"这件事没有任何东西能替它发现。
26
+ *
27
+ * ## 本文件怎么解:**归一到"盘上那一份"**,而且只认文件系统事实
28
+ *
29
+ * 引擎不能自己解:ENGINE 清单里的文件被两道闸禁止直接碰 `node:fs` 与平台分支
30
+ * (`build/make-bundle.mjs` 的 `copyFile` 守卫 + `public-surface-test` …… 见 `read-core.mjs`
31
+ * 顶部那段「为什么是词法 + 别名」),而"这个文件系统是不是大小写敏感"**正是平台事实**。
32
+ * **独立面不受这条限制**(`make-bundle` 里 `aura-standalone/**` 走 host 档:允许 `node:fs`)。
33
+ * 注意:本文件的守卫禁的是**平台分支**(平台字段那个字面量),写在注释里也照样红 ——
34
+ * 所以这段注释与 `read-core.mjs` 里同义的注一样,只描述事实、不写那个字段。
35
+ * 落盘键也正好在这里 —— 引擎每次记账/查询都要经过本文件,于是:
36
+ *
37
+ * · {@link identityKey} = 这个路径**盘上是哪一份文件**:
38
+ * `fs.realpathSync.native` 一次同时解掉大小写、8.3 短名、`.`/`..` 段、冗余分隔符、
39
+ * 符号链接与 junction(本机实测:`…\Beta.js` / `…\beta.js` / `…\BETA.JS` 三种写法都给出
40
+ * 盘上的真名 `…\Beta.js`;`realpathSync` 的 JS 版**不折叠大小写**,所以必须用 `.native`)。
41
+ * · 五个账本入口(remember / latest / entry / recall / has)都先过它,
42
+ * 调用点一处都不重复实现({@link accountKeys} 是唯一那道缝)。
43
+ *
44
+ * ## 为什么这不是"猜",也不会把两个真文件并成一个键
45
+ *
46
+ * 判据是**文件系统给的**,不是平台猜的:`realpath` 只在**两次调用解析到同一份盘上文件**时才相等。
47
+ * 于是——
48
+ * · 大小写敏感目录里 `Beta.js` 与 `beta.js` 并存 ⇒ 两次 realpath 给出两个不同的真名
49
+ * ⇒ 两个键,各自的观测互不顶替(`observation-store-identity-test` 段B 用真·两个文件钉住,
50
+ * `stale-gate-test` 段O 是进程内那条同源判据);
51
+ * · 文件**不存在**(`--create` 场景)⇒ realpath 抛错 ⇒ 退到**纯词法**的 `path.resolve`
52
+ * (只归一 `.` 段、冗余分隔符与盘符大小写,**不折叠文件名大小写**)。这个方向是安全的:
53
+ * 不存在就没有"盘上那一份"可认,宁可认不出来(闸门如实带 `unverified_region`),也不许并键。
54
+ * · 查询侧只在**精确键查不到**时才走归一(`data.latest` 命中就返回)——常见形态下不额外真调
55
+ * 一次 realpath;归一结果按进程缓存,一条命中的路径只调一次。
56
+ *
57
+ * ## 与引擎那一侧的边界(一处实现,不复制第二份)
58
+ *
59
+ * 引擎的 `ledgerPathKey` / `resolveLedgerKey` 仍然是**进程内**那一半(词法 + 会话别名),一行未动;
60
+ * 本文件是**跨进程**那一半(盘上事实)。两者不重叠:引擎那半在引擎里,这半在宿主里,
61
+ * 没有任何判据被写两遍。C54 的三段判据(`stale-gate-test` 段M/N/O)在改后一条不许少。
62
+ */
63
+ import fs from 'node:fs'
64
+ import os from 'node:os'
65
+ import path from 'node:path'
66
+
67
+ const LIMIT = 32
68
+ const MAX_SOURCE = 262144 // 256 KiB:比这大的文件不进缓存
69
+
70
+ /** 缓存目录:默认 `~/.aura`;`AURA_CACHE_DIR` 可覆盖(测试、CI、或不想在 home 里落文件的人)。 */
71
+ export function observationsPath() {
72
+ const dir = process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
73
+ return path.join(dir, 'observations.json')
74
+ }
75
+
76
+ /** 归一结果的进程内缓存(同一个进程里同一条路径只真调一次 realpath)。 */
77
+ const IDENTITY_CACHE = new Map()
78
+ const IDENTITY_CACHE_LIMIT = 4096
79
+
80
+ /**
81
+ * 这个路径**盘上是哪一份文件** —— 跨进程的路径同一性。
82
+ *
83
+ * 判据只有文件系统能给:`realpath` 解掉大小写、8.3 短名、`.`/`..`、冗余分隔符、符号链接与 junction。
84
+ * 拿不到(文件不存在 / 权限 / 路径太怪)就退到纯词法 —— 那个方向**只会认不出来,不会并错**。
85
+ *
86
+ * @param filePath - 调用方给的路径(引擎传进来的是它自己词法归一后的键;非字符串原样返回)
87
+ * @returns 账本键的**同一性**部分
88
+ */
89
+ export function identityKey(filePath) {
90
+ if (typeof filePath !== 'string' || filePath.length === 0) return filePath
91
+ const cached = IDENTITY_CACHE.get(filePath)
92
+ if (cached !== undefined) return cached
93
+ let accounted = null
94
+ try {
95
+ accounted = fs.realpathSync.native(filePath)
96
+ } catch {
97
+ // 不存在 / 解析不了:没有"盘上那一份"可认 ⇒ 下面的纯词法兜底(不折叠文件名大小写)
98
+ try {
99
+ accounted = fs.realpathSync(filePath)
100
+ } catch {
101
+ accounted = path.resolve(filePath)
102
+ }
103
+ }
104
+ IDENTITY_CACHE.set(filePath, accounted)
105
+ while (IDENTITY_CACHE.size > IDENTITY_CACHE_LIMIT) IDENTITY_CACHE.delete(IDENTITY_CACHE.keys().next().value)
106
+ return accounted
107
+ }
108
+
109
+ /**
110
+ * 记账的键:`identityKey` 的别名改写({@link createDiskStore} 里唯一那道缝)。
111
+ *
112
+ * 返回 `{ key, canonical }`:
113
+ * · `canonical` = **盘上那一份**的名字(`null` = 拿不到文件系统事实,别写别名表);
114
+ * · `key` = 这一条目**该存在哪个键下**(盘上那个写法优先,其次这一份已有的别名,最后原样)。
115
+ *
116
+ * 优先级为什么是这个顺序:盘上名字优先,是为了让不同拼写**收敛到同一个存放位置**(否则第一条
117
+ * 在那里、第二条在这里,`data.latest` 会跟着改写的写法来回搬家);一份文件已经有存放位置时不
118
+ * 改搬(`Beta.js` 先被观测、之后才出现 `beta.js` 的写法 ⇒ 观测仍留在 `Beta.js` 键下)。
119
+ */
120
+ function accountKeys(filePath, aliases) {
121
+ const key = identityKey(filePath)
122
+ if (key === filePath) return { key, canonical: null } // 已经是盘上的名字(或拿不到事实)
123
+ const known = aliases.get(key)
124
+ if (known !== undefined) return { key: known, canonical: key }
125
+ return { key, canonical: key }
126
+ }
127
+
128
+ export function createDiskStore(file = observationsPath()) {
129
+ let data = { seen: [], latest: {} }
130
+ try {
131
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
132
+ if (raw !== null && typeof raw === 'object' && Array.isArray(raw.seen) && typeof raw.latest === 'object') data = raw
133
+ } catch {
134
+ // 没有缓存文件 / 内容坏了 ⇒ 当空缓存启动("没观测过"是合法状态,不是错误)
135
+ }
136
+ /**
137
+ * 别名表:`路径写法 → 存放键`(**落盘**,与 seen / latest 同一次 save)。
138
+ *
139
+ * 为什么它必须落盘:跨进程那一半证据只能活在盘上。旧缓存文件没有这一栏 ⇒ 当空表起,
140
+ * 之后的记账会把条目补上(不迁移、不猜)。
141
+ */
142
+ const aliases = new Map(Object.entries(typeof data.aliases === 'object' && data.aliases !== null ? data.aliases : {}))
143
+
144
+ /** 清掉指向已不存在条目的别名:留着它只会让下一次查询解析到一个空键。 */
145
+ const dropDanglingAliases = () => {
146
+ const alive = new Set(data.seen.map((e) => e.key))
147
+ for (const [spelling, target] of aliases) if (!alive.has(target)) aliases.delete(spelling)
148
+ }
149
+
150
+ const save = () => {
151
+ try {
152
+ const dir = path.dirname(file)
153
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
154
+ // 目录已存在时 mkdir 的 mode 不生效;显式收紧一次(Windows 的 mode 语义不同,跳过)。
155
+ if (path.sep !== '\\') {
156
+ try { fs.chmodSync(dir, 0o700) } catch { /* 不是 owner / 文件系统不支持:不阻断缓存 */ }
157
+ }
158
+ const tmp = `${file}.tmp-${process.pid}`
159
+ fs.writeFileSync(tmp, JSON.stringify({ ...data, aliases: Object.fromEntries(aliases) }), { mode: 0o600 })
160
+ if (path.sep !== '\\') {
161
+ try { fs.chmodSync(tmp, 0o600) } catch { /* 同上 */ }
162
+ }
163
+ fs.renameSync(tmp, file)
164
+ if (path.sep !== '\\') {
165
+ try { fs.chmodSync(file, 0o600) } catch { /* 同上 */ }
166
+ }
167
+ } catch {
168
+ // 缓存写不进去**不能**让编辑失败:最坏结果只是下一轮少一次新鲜度判据
169
+ }
170
+ }
171
+ const findEntry = (filePath, version) => data.seen.find((e) => e.key === `${filePath}@${version}`) ?? null
172
+
173
+ return {
174
+ remember(filePath, version, source, byteOrderMark = false) {
175
+ if (typeof source !== 'string' || source.length > MAX_SOURCE) return
176
+ const accounted = accountKeys(filePath, aliases)
177
+ if (accounted.canonical !== null) aliases.set(accounted.canonical, accounted.key)
178
+ const key = `${accounted.key}@${version}`
179
+ data.seen = data.seen.filter((e) => e.key !== key)
180
+ data.seen.push({ key, version, source, byteOrderMark: byteOrderMark === true })
181
+ while (data.seen.length > LIMIT) data.seen.shift()
182
+ data.latest[accounted.key] = key
183
+ // 指针指向已被淘汰的条目时一并清掉:留着它只会让"最近观测"指向不存在的全文
184
+ const alive = new Set(data.seen.map((e) => e.key))
185
+ for (const [p, k] of Object.entries(data.latest)) if (!alive.has(k)) delete data.latest[p]
186
+ dropDanglingAliases()
187
+ save()
188
+ },
189
+ latest(filePath) {
190
+ /**
191
+ * 快路:**精确键**命中就直接返回(引擎传进来的通常是它自己登记过的那个键)。
192
+ * 只有查不到时才用文件系统事实再问一次"这个写法是不是盘上那一份" —— 于是常见形态下
193
+ * 这一步不额外真调 realpath(归一结果本身也按进程缓存)。
194
+ */
195
+ let stored = data.latest[filePath]
196
+ if (stored === undefined) {
197
+ const accounted = accountKeys(filePath, aliases)
198
+ stored = data.latest[accounted.key]
199
+ if (stored === undefined && accounted.canonical !== null && accounted.key !== accounted.canonical) {
200
+ stored = data.latest[accounted.canonical]
201
+ }
202
+ }
203
+ if (stored === undefined) return null
204
+ const entry = data.seen.find((e) => e.key === stored)
205
+ if (entry === undefined) return null
206
+ return { version: entry.version, source: entry.source, byteOrderMark: entry.byteOrderMark === true }
207
+ },
208
+ entry(filePath, version) {
209
+ const direct = findEntry(filePath, version)
210
+ if (direct !== null) return { source: direct.source, byteOrderMark: direct.byteOrderMark === true }
211
+ const accounted = accountKeys(filePath, aliases)
212
+ const entry = findEntry(accounted.key, version) ?? (accounted.canonical === null ? null : findEntry(accounted.canonical, version))
213
+ return entry === null ? null : { source: entry.source, byteOrderMark: entry.byteOrderMark === true }
214
+ },
215
+ recall(filePath, version) {
216
+ const direct = findEntry(filePath, version)
217
+ if (direct !== null) return direct.source
218
+ const accounted = accountKeys(filePath, aliases)
219
+ return findEntry(accounted.key, version)?.source ?? (accounted.canonical === null ? null : findEntry(accounted.canonical, version)?.source) ?? null
220
+ },
221
+ /**
222
+ * “路径@版本”在不在账本里。
223
+ *
224
+ * 为什么单要这个方法:`latest` 是**按路径**给最新一条,回答不了"现在磁盘上这份内容,
225
+ * 是不是我们观测过的那一版";而闸门要问的正是后者(T21)——
226
+ * 不在账本里就说明这份内容是在我们最后一次观测之后由别人写下的,
227
+ * 模型手上的行号来自更早的一版,且没有任何东西能替它发现这件事。
228
+ */
229
+ has(filePath, version) {
230
+ if (findEntry(filePath, version) !== null) return true
231
+ const accounted = accountKeys(filePath, aliases)
232
+ return findEntry(accounted.key, version) !== null || (accounted.canonical !== null && findEntry(accounted.canonical, version) !== null)
233
+ },
234
+ count: () => data.seen.length,
235
+ }
236
+ }