aura-basetools 0.0.0-stage → 0.13.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/LICENSE +21 -0
- package/README.md +236 -2
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/bin/aura-mcp.mjs +566 -0
- package/bin/aura.mjs +616 -0
- package/lib/fs-provider.mjs +355 -0
- package/lib/observation-store.mjs +369 -0
- package/lib/ops.mjs +662 -0
- package/lib/transaction-store.mjs +350 -0
- package/lib/workspace-lock.mjs +151 -0
- package/locator-lab/anchor-core.mjs +586 -0
- package/locator-lab/args.mjs +588 -0
- package/locator-lab/handle-core.mjs +207 -0
- package/locator-lab/locator-api.mjs +1159 -0
- package/locator-lab/read-core.mjs +3084 -0
- package/locator-lab/repo-map.mjs +768 -0
- package/locator-lab/struct-core.mjs +3084 -0
- package/locator-lab/write-path.mjs +3995 -0
- package/package.json +50 -4
- package/test/argument-surface-test.mjs +119 -0
- package/test/doctor-test.mjs +73 -0
- package/test/observation-concurrency-test.mjs +90 -0
- package/test/standalone-test.mjs +1649 -0
- package/test/write-test.mjs +294 -0
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
/**
|
|
151
|
+
* 落盘状态的新鲜度标记。多个 CLI/MCP 进程会同时读写同一个 `observations.json`;
|
|
152
|
+
* 旧实现“启动时读一次、结束时整份写回”会让后写的进程覆盖先写的观测(并发读两个文件,
|
|
153
|
+
* 最后只剩一条)——严格新鲜度随后对刚读过的文件报 `never-read`。
|
|
154
|
+
*/
|
|
155
|
+
let lastStamp = null
|
|
156
|
+
const fileStamp = () => {
|
|
157
|
+
try {
|
|
158
|
+
const st = fs.statSync(file)
|
|
159
|
+
return `${st.mtimeMs}:${st.size}`
|
|
160
|
+
} catch {
|
|
161
|
+
return 'missing'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
lastStamp = fileStamp()
|
|
165
|
+
|
|
166
|
+
/** 从盘上重新读一份合法缓存;坏文件/不存在都当空(“没观测过”是合法状态)。 */
|
|
167
|
+
const loadDisk = () => {
|
|
168
|
+
try {
|
|
169
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
170
|
+
if (raw !== null && typeof raw === 'object' && Array.isArray(raw.seen) && typeof raw.latest === 'object') return raw
|
|
171
|
+
} catch {
|
|
172
|
+
/* 落到 null */
|
|
173
|
+
}
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 把盘上条目并进内存:seen 按 key 去重(本地优先),latest/aliases 本地优先。 */
|
|
178
|
+
const mergeDisk = (disk) => {
|
|
179
|
+
if (disk === null) return
|
|
180
|
+
const seen = new Map()
|
|
181
|
+
for (const entry of disk.seen) seen.set(entry.key, entry)
|
|
182
|
+
for (const entry of data.seen) seen.set(entry.key, entry)
|
|
183
|
+
data.seen = [...seen.values()]
|
|
184
|
+
data.latest = { ...disk.latest, ...data.latest }
|
|
185
|
+
if (typeof disk.aliases === 'object' && disk.aliases !== null) {
|
|
186
|
+
for (const [spelling, target] of Object.entries(disk.aliases)) {
|
|
187
|
+
if (!aliases.has(spelling)) aliases.set(spelling, target)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 查询前刷新:长进程(MCP)也能看见别的进程刚写下的观测。 */
|
|
193
|
+
const refresh = () => {
|
|
194
|
+
const stamp = fileStamp()
|
|
195
|
+
if (stamp === lastStamp) return
|
|
196
|
+
mergeDisk(loadDisk())
|
|
197
|
+
lastStamp = stamp
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 写锁:同一个缓存文件同时只允许一个进程做 read-merge-write。
|
|
202
|
+
*
|
|
203
|
+
* 锁是**旁路文件**(`observations.json.lock`,`wx` 原子创建),不引入平台分支;
|
|
204
|
+
* 陈旧锁(进程崩溃留下)超过 `LOCK_STALE_MS` 后清掉。拿不到锁时仍做一次尽力而为的
|
|
205
|
+
* 合并写入——缓存损坏/丢一条都不该让编辑失败,但正常情况下不会走到这条。
|
|
206
|
+
*/
|
|
207
|
+
const LOCK_STALE_MS = 5000
|
|
208
|
+
const LOCK_WAIT_MS = 2000
|
|
209
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
210
|
+
const acquireLock = () => {
|
|
211
|
+
const lock = `${file}.lock`
|
|
212
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
213
|
+
try {
|
|
214
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
215
|
+
} catch {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
for (;;) {
|
|
219
|
+
try {
|
|
220
|
+
const fd = fs.openSync(lock, 'wx', 0o600)
|
|
221
|
+
try { fs.writeFileSync(fd, String(process.pid)) } finally { fs.closeSync(fd) }
|
|
222
|
+
return lock
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code !== 'EEXIST') return null
|
|
225
|
+
try {
|
|
226
|
+
const st = fs.statSync(lock)
|
|
227
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
228
|
+
fs.unlinkSync(lock)
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (Date.now() >= deadline) return null
|
|
235
|
+
sleep(10)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const releaseLock = (lock) => {
|
|
240
|
+
if (lock === null) return
|
|
241
|
+
try { fs.unlinkSync(lock) } catch { /* 已被清理:没有需要撤销的状态 */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const save = () => {
|
|
245
|
+
try {
|
|
246
|
+
const dir = path.dirname(file)
|
|
247
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
248
|
+
// 目录已存在时 mkdir 的 mode 不生效;显式收紧一次(Windows 的 mode 语义不同,跳过)。
|
|
249
|
+
if (path.sep !== '\\') {
|
|
250
|
+
try { fs.chmodSync(dir, 0o700) } catch { /* 不是 owner / 文件系统不支持:不阻断缓存 */ }
|
|
251
|
+
}
|
|
252
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
253
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...data, aliases: Object.fromEntries(aliases) }), { mode: 0o600 })
|
|
254
|
+
if (path.sep !== '\\') {
|
|
255
|
+
try { fs.chmodSync(tmp, 0o600) } catch { /* 同上 */ }
|
|
256
|
+
}
|
|
257
|
+
fs.renameSync(tmp, file)
|
|
258
|
+
lastStamp = fileStamp()
|
|
259
|
+
if (path.sep !== '\\') {
|
|
260
|
+
try { fs.chmodSync(file, 0o600) } catch { /* 同上 */ }
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// 缓存写不进去**不能**让编辑失败:最坏结果只是下一轮少一次新鲜度判据
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const findEntry = (filePath, version) => data.seen.find((e) => e.key === `${filePath}@${version}`) ?? null
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
remember(filePath, version, source, byteOrderMark = false) {
|
|
270
|
+
if (typeof source !== 'string') return
|
|
271
|
+
/**
|
|
272
|
+
* 超过 `MAX_SOURCE` 的文件**仍然记令牌**,只是不存全文。
|
|
273
|
+
*
|
|
274
|
+
* 旧行为是整条丢掉,于是严格新鲜度(`requireObservedRevision`)对大文件永远返回
|
|
275
|
+
* `never-read`——而它要求的恢复动作(先读一次)根本不可能成功。令牌足够回答
|
|
276
|
+
* “这一版读过没有”;区域级闸门拿不到全文时如实退回 `basis:'none'`(见 `regionGate`)。
|
|
277
|
+
*/
|
|
278
|
+
const omitted = source.length > MAX_SOURCE
|
|
279
|
+
const lock = acquireLock()
|
|
280
|
+
try {
|
|
281
|
+
// 先合并盘上条目,再算键与写入:否则两个并发进程会各自用旧快照覆盖对方。
|
|
282
|
+
mergeDisk(loadDisk())
|
|
283
|
+
const accounted = accountKeys(filePath, aliases)
|
|
284
|
+
if (accounted.canonical !== null) aliases.set(accounted.canonical, accounted.key)
|
|
285
|
+
const key = `${accounted.key}@${version}`
|
|
286
|
+
data.seen = data.seen.filter((e) => e.key !== key)
|
|
287
|
+
data.seen.push({
|
|
288
|
+
key,
|
|
289
|
+
version,
|
|
290
|
+
source: omitted ? null : source,
|
|
291
|
+
...(omitted ? { source_omitted: true } : {}),
|
|
292
|
+
byteOrderMark: byteOrderMark === true,
|
|
293
|
+
at: Date.now(),
|
|
294
|
+
})
|
|
295
|
+
while (data.seen.length > LIMIT) data.seen.shift()
|
|
296
|
+
data.latest[accounted.key] = key
|
|
297
|
+
// 指针指向已被淘汰的条目时一并清掉:留着它只会让"最近观测"指向不存在的全文
|
|
298
|
+
const alive = new Set(data.seen.map((e) => e.key))
|
|
299
|
+
for (const [p, k] of Object.entries(data.latest)) if (!alive.has(k)) delete data.latest[p]
|
|
300
|
+
dropDanglingAliases()
|
|
301
|
+
save()
|
|
302
|
+
} finally {
|
|
303
|
+
releaseLock(lock)
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
latest(filePath) {
|
|
307
|
+
refresh()
|
|
308
|
+
/**
|
|
309
|
+
* 快路:**精确键**命中就直接返回(引擎传进来的通常是它自己登记过的那个键)。
|
|
310
|
+
* 只有查不到时才用文件系统事实再问一次"这个写法是不是盘上那一份" —— 于是常见形态下
|
|
311
|
+
* 这一步不额外真调 realpath(归一结果本身也按进程缓存)。
|
|
312
|
+
*/
|
|
313
|
+
let stored = data.latest[filePath]
|
|
314
|
+
if (stored === undefined) {
|
|
315
|
+
const accounted = accountKeys(filePath, aliases)
|
|
316
|
+
stored = data.latest[accounted.key]
|
|
317
|
+
if (stored === undefined && accounted.canonical !== null && accounted.key !== accounted.canonical) {
|
|
318
|
+
stored = data.latest[accounted.canonical]
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (stored === undefined) return null
|
|
322
|
+
const entry = data.seen.find((e) => e.key === stored)
|
|
323
|
+
if (entry === undefined) return null
|
|
324
|
+
return {
|
|
325
|
+
version: entry.version,
|
|
326
|
+
source: entry.source,
|
|
327
|
+
byteOrderMark: entry.byteOrderMark === true,
|
|
328
|
+
source_omitted: entry.source_omitted === true,
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
entry(filePath, version) {
|
|
332
|
+
refresh()
|
|
333
|
+
const direct = findEntry(filePath, version)
|
|
334
|
+
if (direct !== null) {
|
|
335
|
+
return { source: direct.source, byteOrderMark: direct.byteOrderMark === true, source_omitted: direct.source_omitted === true }
|
|
336
|
+
}
|
|
337
|
+
const accounted = accountKeys(filePath, aliases)
|
|
338
|
+
const entry = findEntry(accounted.key, version) ?? (accounted.canonical === null ? null : findEntry(accounted.canonical, version))
|
|
339
|
+
return entry === null
|
|
340
|
+
? null
|
|
341
|
+
: { source: entry.source, byteOrderMark: entry.byteOrderMark === true, source_omitted: entry.source_omitted === true }
|
|
342
|
+
},
|
|
343
|
+
recall(filePath, version) {
|
|
344
|
+
refresh()
|
|
345
|
+
const direct = findEntry(filePath, version)
|
|
346
|
+
if (direct !== null) return direct.source
|
|
347
|
+
const accounted = accountKeys(filePath, aliases)
|
|
348
|
+
return findEntry(accounted.key, version)?.source ?? (accounted.canonical === null ? null : findEntry(accounted.canonical, version)?.source) ?? null
|
|
349
|
+
},
|
|
350
|
+
/**
|
|
351
|
+
* “路径@版本”在不在账本里。
|
|
352
|
+
*
|
|
353
|
+
* 为什么单要这个方法:`latest` 是**按路径**给最新一条,回答不了"现在磁盘上这份内容,
|
|
354
|
+
* 是不是我们观测过的那一版";而闸门要问的正是后者(T21)——
|
|
355
|
+
* 不在账本里就说明这份内容是在我们最后一次观测之后由别人写下的,
|
|
356
|
+
* 模型手上的行号来自更早的一版,且没有任何东西能替它发现这件事。
|
|
357
|
+
*/
|
|
358
|
+
has(filePath, version) {
|
|
359
|
+
refresh()
|
|
360
|
+
if (findEntry(filePath, version) !== null) return true
|
|
361
|
+
const accounted = accountKeys(filePath, aliases)
|
|
362
|
+
return findEntry(accounted.key, version) !== null || (accounted.canonical !== null && findEntry(accounted.canonical, version) !== null)
|
|
363
|
+
},
|
|
364
|
+
count: () => {
|
|
365
|
+
refresh()
|
|
366
|
+
return data.seen.length
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
}
|