@geoly-ai/skills-hub 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
@@ -0,0 +1,435 @@
1
+ // journal —— write-ahead 事务日志的读写与**严格**校验。
2
+ //
3
+ // 规格:04-install.md §5.2(第 6 步写什么)、§5.3(逐项段模型)、§5.4(合法取值与
4
+ // 持久化时点、只有这些情况判 corrupt)、§5.4.1(rollback 的正式 schema、入场分类
5
+ // 封闭表、(op,state,cleanup,entry_class) 一致性矩阵)、§5.4.2(ledger_image 契约)、
6
+ // §4.2(adopt_assertions / unadopt_assertions)、11-wire-contract.md §2/§3/§5/§7。
7
+ //
8
+ // 🔴 本模块只做「读写 + 校验」,**不做调度**。段的推进在 install.mjs,
9
+ // 反向段的调度在 recover.mjs。把调度混进来会让「正向 state 只用于一致性校验」
10
+ // 这条铁律(§5.4.1 v37)在实现层被悄悄破坏。
11
+ //
12
+ // 🔴 全部写入走 src/atomic-fs.mjs —— 绕过它的裸 fs 写对持久性影子不可见,
13
+ // 崩溃测试会**静默地测不到**。
14
+
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { parseStrict, stringify } from './canonical-json.mjs';
18
+ import { writeAtomic, rmtreeFsync } from './atomic-fs.mjs';
19
+ import { crc32cHex } from './crc32c.mjs';
20
+ import { isSafeSegment } from './safe-fs.mjs';
21
+
22
+ export const JOURNAL_SCHEMA = 'geoly.skills.journal/1';
23
+
24
+ /** §11 §2:单个 JSON 文档 ≤ 8 MiB,**解析前先查文件大小** */
25
+ export const MAX_JSON_BYTES = 8 * 1024 * 1024;
26
+
27
+ /**
28
+ * fail-closed 停机。
29
+ * 🔴 `corrupt = true` 是整个内核与测试框架共用的判据(子进程以 91 退出)。
30
+ */
31
+ export class Corrupt extends Error {
32
+ constructor(msg) {
33
+ super(`corrupt: ${msg}`);
34
+ this.name = 'Corrupt';
35
+ this.corrupt = true;
36
+ this.code = 5;
37
+ }
38
+ }
39
+ export const bad = (msg) => { throw new Corrupt(msg); };
40
+
41
+ // ── 枚举(§5.4「合法取值」)──────────────────────────────────────────────────
42
+
43
+ /** 🔴 只有三种 item op。`logical-only` **不是**第四种(§5.10 v27)。 */
44
+ export const ITEM_OPS = ['swap', 'install-new', 'retire-only'];
45
+ /** 🔴 **没有 `retiring`** —— v10 的段模型取消了它(§5.3/§5.4)。 */
46
+ export const ITEM_STATES = ['planned', 'retired', 'swapped', 'verified', 'done', 'corrupt'];
47
+ /** `item.cleanup`:缺席 → tar_durable → done */
48
+ export const CLEANUP_STATES = ['tar_durable', 'done'];
49
+ export const PHASES = ['prepared', 'cleanup_pending', 'completed'];
50
+ export const ENTRY_CLASSES = [
51
+ 'noop', 'as-retired', 'as-retired-cleaned', 'as-swapped', 'as-swapped-cleaned', 'as-installed',
52
+ ];
53
+ export const RSTATES = ['pending', 't_parked', 'restored'];
54
+ export const ASSERTION_STATES = ['ok', 'assertion-corrupt'];
55
+
56
+ /** §5.4.1「合法迁移」—— 其余一律 corrupt */
57
+ export const RSTATE_PATH = {
58
+ noop: ['restored'],
59
+ 'as-retired': ['pending', 'restored'],
60
+ 'as-retired-cleaned': ['pending', 'restored'],
61
+ 'as-swapped': ['pending', 't_parked', 'restored'],
62
+ 'as-swapped-cleaned': ['pending', 't_parked', 'restored'],
63
+ 'as-installed': ['pending', 'restored'],
64
+ };
65
+
66
+ /**
67
+ * §5.4.1 `(op, 正向 state, cleanup, entry_class)` 的**闭合一致性矩阵**。
68
+ * 🔴 只做校验,不做调度;未列组合即 `corrupt`(§11 §7「未定义即拒绝」)。
69
+ *
70
+ * key = `${op}|${state}|${cleanup ?? '-'}`
71
+ */
72
+ export const CONSISTENCY = {
73
+ // op = swap
74
+ 'swap|planned|-': ['noop', 'as-retired'],
75
+ 'swap|retired|-': ['as-retired', 'as-swapped'],
76
+ 'swap|swapped|-': ['as-swapped'],
77
+ 'swap|verified|-': ['as-swapped'],
78
+ 'swap|done|-': ['as-swapped'],
79
+ // 🔴 tar_durable 是 checkpoint,**递归删除发生在它之后** —— retired/ 可能完整、
80
+ // 也可能已被删到部分或空。v38 只允许 cleaned,与清理协议矛盾。
81
+ 'swap|done|tar_durable': ['as-swapped', 'as-swapped-cleaned'],
82
+ 'swap|done|done': ['as-swapped-cleaned'],
83
+
84
+ // op = retire-only
85
+ 'retire-only|planned|-': ['noop', 'as-retired'],
86
+ 'retire-only|retired|-': ['as-retired'],
87
+ 'retire-only|verified|-': ['as-retired'],
88
+ 'retire-only|done|-': ['as-retired'],
89
+ 'retire-only|done|tar_durable': ['as-retired', 'as-retired-cleaned'],
90
+ 'retire-only|done|done': ['as-retired-cleaned'],
91
+
92
+ // op = install-new —— 🔴 它也有 cleanup 维度(v38 漏了):§5.6 对它是空操作,
93
+ // 但 cleanup 字段仍会依次经过 缺席 → tar_durable → done,三种都必须显式列出。
94
+ 'install-new|planned|-': ['noop', 'as-installed'],
95
+ 'install-new|swapped|-': ['as-installed'],
96
+ 'install-new|verified|-': ['as-installed'],
97
+ 'install-new|done|-': ['as-installed'],
98
+ 'install-new|done|tar_durable': ['as-installed'],
99
+ 'install-new|done|done': ['as-installed'],
100
+ };
101
+
102
+ /** §11 §2 的摘要形式 */
103
+ const RE_TREE_DIGEST = /^geoly-tree-v1:sha256:[0-9a-f]{64}$/;
104
+ const RE_TX_DIGEST = /^geoly-tx-v1:sha256:[0-9a-f]{64}$/;
105
+ const RE_SHA256 = /^sha256:[0-9a-f]{64}$/;
106
+ export const isTreeDigest = (s) => typeof s === 'string' && RE_TREE_DIGEST.test(s);
107
+ export const isTxDigest = (s) => typeof s === 'string' && RE_TX_DIGEST.test(s);
108
+ export const isSha256 = (s) => typeof s === 'string' && RE_SHA256.test(s);
109
+ export const isUint = (n) => Number.isSafeInteger(n) && n >= 0;
110
+
111
+ /**
112
+ * 🔴 **持久化 map 的 key 也是不可信输入**(Codex 第三轮 P0)。
113
+ *
114
+ * `journal.items` / `adopt_assertions` / `rollback.items` / `ledger.entries` /
115
+ * `manifest.items` / `repair-intent.plan.items` 的键随后都会进 `join()`、`rename`、
116
+ * `rmtree` —— 一个 `../` 或绝对路径就能把清理动作导出 `.geoly` 之外。
117
+ * schema 校验只看值不看键,正好漏掉这一类。
118
+ *
119
+ * 判据:**单个安全 segment**(同 §01-4 的路径 grammar),不是「一条相对路径」——
120
+ * skill 的名字就是磁盘上的一级目录名,本来就不该带斜杠。
121
+ */
122
+ export function assertSafeName(name, where) {
123
+ if (!isSafeSegment(name)) bad(`${where}:${JSON.stringify(name)} 不是合法的单段目录名(路径穿越防线)`);
124
+ // 🔴 `isSafeSegment` 的字符集 `[A-Za-z0-9._-]+` **本身允许 `.` 与 `..`** ——
125
+ // 实测确认过。只调它就等于把路径穿越放进来了;`..` 必须在**分段之后**单独判
126
+ // (同 safe-fs 里那条注释:不能只做字符串 includes,也不能只做字符集匹配)。
127
+ if (name === '.' || name === '..') bad(`${where}:${name} 是路径 segment,不得作为名字`);
128
+ if (name === '.geoly') bad(`${where}:${name} 是状态目录名,不得作为 entry 名`);
129
+ return name;
130
+ }
131
+
132
+ // ── 通用的「未知字段即拒绝」(§11 §2)───────────────────────────────────────
133
+
134
+ export function assertKeys(obj, required, optional, where) {
135
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
136
+ bad(`${where} 必须是对象`);
137
+ }
138
+ const keys = new Set(Object.keys(obj));
139
+ for (const k of required) if (!keys.has(k)) bad(`${where} 缺必填字段 ${k}`);
140
+ const allowed = new Set([...required, ...optional]);
141
+ for (const k of keys) if (!allowed.has(k)) bad(`${where} 出现未知字段 ${k}`);
142
+ }
143
+
144
+ /** §11 §2:解析前先查文件大小;解析用 parseStrict(拒绝重复 key) */
145
+ export function readJsonStrict(path, where) {
146
+ const size = statSync(path).size;
147
+ if (size > MAX_JSON_BYTES) bad(`${where} ${path} 有 ${size} 字节,超过 8 MiB 上限`);
148
+ const text = readFileSync(path, 'utf8');
149
+ try {
150
+ return parseStrict(text);
151
+ } catch (e) {
152
+ throw new Corrupt(`${where} ${path} 解析失败:${e.message}`);
153
+ }
154
+ }
155
+
156
+ // ── ledger_image(§5.4.2)───────────────────────────────────────────────────
157
+
158
+ /**
159
+ * 🔴 `entries` / `roots` 的值允许 `null` 哨兵(§11 §2 白名单里逐个列出的位置),
160
+ * 语义是「该键复位后应不存在 / 本次删除它」。**不是**「不 patch」。
161
+ */
162
+ function validateImageSide(side, where, { allowAuditAppend }) {
163
+ assertKeys(side, ['entries', 'roots', 'last_applied_generation'],
164
+ allowAuditAppend ? ['frozen_attic', 'audit_append'] : ['frozen_attic'], where);
165
+ for (const k of ['entries', 'roots']) {
166
+ const m = side[k];
167
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) bad(`${where}.${k} 必须是对象`);
168
+ for (const [kk, vv] of Object.entries(m)) {
169
+ if (vv !== null && (typeof vv !== 'object' || Array.isArray(vv))) {
170
+ bad(`${where}.${k}[${kk}] 只允许对象或 null 哨兵`);
171
+ }
172
+ }
173
+ }
174
+ if (!isUint(side.last_applied_generation)) bad(`${where}.last_applied_generation 必须是非负整数`);
175
+ if ('frozen_attic' in side && side.frozen_attic !== null
176
+ && (typeof side.frozen_attic !== 'object' || Array.isArray(side.frozen_attic))) {
177
+ bad(`${where}.frozen_attic 只允许对象或 null 哨兵`);
178
+ }
179
+ if ('audit_append' in side) {
180
+ if (!Array.isArray(side.audit_append)) bad(`${where}.audit_append 必须是数组`);
181
+ for (const e of side.audit_append) validateAuditEvent(e, `${where}.audit_append[]`);
182
+ }
183
+ }
184
+
185
+ /** §4 的 audit 事件里 `kind` 的**封闭枚举** —— §11 §7「未定义即拒绝」 */
186
+ export const AUDIT_KINDS = [
187
+ 'installed-yanked', 'restored-yanked', 'restored-degraded', 'restored-state-unknown',
188
+ ];
189
+
190
+ /** §4 的 audit 事件。🔴 `advisory` 一律「没有就缺席」,不是 `null`。 */
191
+ export function validateAuditEvent(e, where) {
192
+ assertKeys(e, ['event_id', 'kind', 'subject', 'at'], ['artifact', 'advisory', 'note'], where);
193
+ if (!isUint(e.event_id)) bad(`${where}.event_id 必须是非负整数`);
194
+ if (e.event_id > Number.MAX_SAFE_INTEGER) bad(`${where}.event_id 超过 2^53-1`);
195
+ if (!AUDIT_KINDS.includes(e.kind)) bad(`${where}.kind 未知取值 ${JSON.stringify(e.kind)}`);
196
+ for (const k of ['artifact', 'advisory', 'note']) {
197
+ if (k in e && (typeof e[k] !== 'string' || e[k] === '')) bad(`${where}.${k} 必须是非空字符串`);
198
+ }
199
+ if ('advisory' in e && !/^GSA-/.test(e.advisory)) bad(`${where}.advisory 必须形如 GSA-…`);
200
+ assertKeys(e.subject, ['kind'], ['name'], `${where}.subject`);
201
+ if (!['entry', 'target'].includes(e.subject.kind)) bad(`${where}.subject.kind 只能是 entry/target`);
202
+ if (e.subject.kind === 'entry' && typeof e.subject.name !== 'string') {
203
+ bad(`${where}.subject.name 在 kind=entry 时必填`);
204
+ }
205
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(e.at)) bad(`${where}.at 必须是严格 UTC 时间`);
206
+ }
207
+
208
+ export function validateLedgerImage(img, where = 'ledger_image') {
209
+ assertKeys(img, ['ledger_existed', 'pre', 'post'], [], where);
210
+ if (typeof img.ledger_existed !== 'boolean') bad(`${where}.ledger_existed 必须是布尔`);
211
+ validateImageSide(img.pre, `${where}.pre`, { allowAuditAppend: false });
212
+ // 🔴 安装事务的 post 里**只有** audit_append 一个 audit 相关字段;
213
+ // audit_archived_until 不在此处(§4「cursor 的唯一前进点」)。
214
+ validateImageSide(img.post, `${where}.post`, { allowAuditAppend: true });
215
+ }
216
+
217
+ // ── 逐项校验 ─────────────────────────────────────────────────────────────────
218
+
219
+ /** §11 §2:`old_digest` / `new_digest` **按 `op` 定义必填/缺席**,不补裸 `null` */
220
+ function validateItem(name, it) {
221
+ const where = `journal.items[${name}]`;
222
+ assertSafeName(name, 'journal.items 的键');
223
+ assertKeys(it, ['op', 'had_old', 'state'], ['old_digest', 'new_digest', 'cleanup'], where);
224
+ if (!ITEM_OPS.includes(it.op)) bad(`${where}.op 未知取值 ${it.op}`);
225
+ if (typeof it.had_old !== 'boolean') bad(`${where}.had_old 必须是布尔`);
226
+ if (it.had_old !== (it.op !== 'install-new')) bad(`${where}.had_old 与 op 不一致`);
227
+ if (!ITEM_STATES.includes(it.state)) bad(`${where}.state 未知取值 ${it.state}`);
228
+ if ('cleanup' in it && !CLEANUP_STATES.includes(it.cleanup)) {
229
+ bad(`${where}.cleanup 未知取值 ${it.cleanup}`);
230
+ }
231
+ const needOld = it.op !== 'install-new';
232
+ const needNew = it.op !== 'retire-only';
233
+ if (needOld && !isTreeDigest(it.old_digest)) bad(`${where}.old_digest 在 op=${it.op} 时必填且须是树摘要`);
234
+ if (!needOld && 'old_digest' in it) bad(`${where}.old_digest 在 op=install-new 时必须缺席`);
235
+ if (needNew && !isTreeDigest(it.new_digest)) bad(`${where}.new_digest 在 op=${it.op} 时必填且须是树摘要`);
236
+ if (!needNew && 'new_digest' in it) bad(`${where}.new_digest 在 op=retire-only 时必须缺席`);
237
+ // 🔴 §5.4.1 入场预检:禁止 swap 的 old_digest == new_digest —— 否则「T==new 要 park」
238
+ // 与「T==old 不 park」两条规则无优先级、判不出来。结构门在生成 plan 时就该拒,
239
+ // 这里是运行时的第二道(改一处就去看它的镜像)。
240
+ if (it.op === 'swap' && it.old_digest === it.new_digest) {
241
+ bad(`${where}:swap 的 old_digest == new_digest,物理 swap 不可判定(出路见 §4.2 的逐字节相同分支)`);
242
+ }
243
+ }
244
+
245
+ function validateAssertionMap(m, key, obj) {
246
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) bad(`journal.${key} 必须是对象`);
247
+ // 🔴 §11:没有对应逻辑项时**整个字段缺席**(不写 {},也不写 null)
248
+ if (Object.keys(m).length === 0) bad(`journal.${key} 为空时必须整个字段缺席,不得写 {}`);
249
+ for (const [name, a] of Object.entries(m)) {
250
+ const where = `journal.${key}[${name}]`;
251
+ assertSafeName(name, `journal.${key} 的键`);
252
+ assertKeys(a, ['artifact', 'tree_digest', 'state'], [], where);
253
+ if (typeof a.artifact !== 'string' || a.artifact === '') bad(`${where}.artifact 必填`);
254
+ if (!isTreeDigest(a.tree_digest)) bad(`${where}.tree_digest 必须是树摘要`);
255
+ if (!ASSERTION_STATES.includes(a.state)) bad(`${where}.state 未知取值 ${a.state}`);
256
+ }
257
+ void obj;
258
+ }
259
+
260
+ /** 🔴 §4.2:`items` / `adopt_assertions` / `unadopt_assertions` **三者互不相交** */
261
+ export function allItemKeys(J) {
262
+ const phys = Object.keys(J.items);
263
+ const ad = Object.keys(J.adopt_assertions ?? {});
264
+ const un = Object.keys(J.unadopt_assertions ?? {});
265
+ const seen = new Set();
266
+ for (const [label, list] of [['items', phys], ['adopt_assertions', ad], ['unadopt_assertions', un]]) {
267
+ for (const n of list) {
268
+ if (seen.has(n)) bad(`journal:${n} 同时出现在多个键集里(${label}),三者必须互不相交`);
269
+ seen.add(n);
270
+ }
271
+ }
272
+ return { phys, ad, un, all: [...seen] };
273
+ }
274
+
275
+ /**
276
+ * §5.4.1 rollback 的正式 schema + 入场分类的合法组合。
277
+ * 🔴 `direction` 与 `rollback` **同时存在或同时缺席**;只有其一 → corrupt。
278
+ */
279
+ function validateRollback(J) {
280
+ const hasDir = 'direction' in J;
281
+ const hasRb = 'rollback' in J;
282
+ if (hasDir !== hasRb) bad('journal:direction 与 rollback 必须同时存在或同时缺席');
283
+ if (!hasDir) return;
284
+ if (J.direction !== 'rollback') bad(`journal.direction 未知取值 ${J.direction}`);
285
+ assertKeys(J.rollback, ['items'], [], 'journal.rollback');
286
+ const rb = J.rollback.items;
287
+ if (rb === null || typeof rb !== 'object' || Array.isArray(rb)) bad('journal.rollback.items 必须是对象');
288
+
289
+ // 🔴 键集严格等于 items ∪ adopt_assertions ∪ unadopt_assertions,多一个少一个都 corrupt。
290
+ // (可以为空 {} —— logical-only 的空 items 事务。)
291
+ const { phys, ad, un, all } = allItemKeys(J);
292
+ const want = new Set(all);
293
+ const got = new Set(Object.keys(rb));
294
+ for (const k of want) if (!got.has(k)) bad(`journal.rollback.items 缺少 ${k}`);
295
+ for (const k of got) if (!want.has(k)) bad(`journal.rollback.items 多出 ${k}`);
296
+
297
+ for (const [name, r] of Object.entries(rb)) {
298
+ const where = `journal.rollback.items[${name}]`;
299
+ assertSafeName(name, 'journal.rollback.items 的键');
300
+ assertKeys(r, ['entry_class', 'rstate'], [], where);
301
+ if (!ENTRY_CLASSES.includes(r.entry_class)) bad(`${where}.entry_class 未知取值 ${r.entry_class}`);
302
+ if (!RSTATES.includes(r.rstate)) bad(`${where}.rstate 未知取值 ${r.rstate}`);
303
+ if (!RSTATE_PATH[r.entry_class].includes(r.rstate)) {
304
+ bad(`${where}:entry_class=${r.entry_class} 不允许 rstate=${r.rstate}`);
305
+ }
306
+ if (phys.includes(name)) {
307
+ const it = J.items[name];
308
+ assertConsistent(name, it, r.entry_class);
309
+ } else if (ad.includes(name)) {
310
+ // adopt:state=ok 或 assertion-corrupt,两者都只允许 noop
311
+ if (r.entry_class !== 'noop') bad(`${where}:adopt 项只允许 entry_class=noop`);
312
+ } else if (un.includes(name)) {
313
+ if (J.unadopt_assertions[name].state === 'assertion-corrupt') {
314
+ // 🔴 §5.4 通用规则:unadopt 的 assertion-corrupt **不进入 rollback**
315
+ bad(`${where}:unadopt 的 assertion-corrupt 不允许 rollback(唯一自动出路是 --continue)`);
316
+ }
317
+ if (r.entry_class !== 'noop') bad(`${where}:unadopt 项只允许 entry_class=noop`);
318
+ }
319
+ }
320
+ }
321
+
322
+ /** 查一致性矩阵。🔴 未列组合即 corrupt。 */
323
+ export function assertConsistent(name, it, entryClass) {
324
+ const key = `${it.op}|${it.state}|${it.cleanup ?? '-'}`;
325
+ const allowed = CONSISTENCY[key];
326
+ if (!allowed) bad(`journal.items[${name}]:(op,state,cleanup)=${key} 不在闭合一致性矩阵内`);
327
+ if (!allowed.includes(entryClass)) {
328
+ bad(`journal.items[${name}]:${key} 不允许 entry_class=${entryClass}(允许 ${allowed.join('/')})`);
329
+ }
330
+ }
331
+
332
+ /** 只校验 (op,state,cleanup) 本身是否是矩阵里的一个合法行 —— 正向路径也要用 */
333
+ export function assertStateCombo(name, it) {
334
+ const key = `${it.op}|${it.state}|${it.cleanup ?? '-'}`;
335
+ if (it.state === 'corrupt') {
336
+ // corrupt 是终态,不进矩阵(§5.4.1 已规定任一项 corrupt 则整个事务不允许 rollback)
337
+ if ('cleanup' in it) bad(`journal.items[${name}]:corrupt 项不应有 cleanup`);
338
+ return;
339
+ }
340
+ if (!CONSISTENCY[key]) bad(`journal.items[${name}]:(op,state,cleanup)=${key} 不是合法组合`);
341
+ }
342
+
343
+ // ── 整体校验 ─────────────────────────────────────────────────────────────────
344
+
345
+ export function validateJournal(J) {
346
+ assertKeys(J,
347
+ ['schema', 'generation', 'tx_dir', 'phase', 'items', 'ledger_image'],
348
+ ['adopt_assertions', 'unadopt_assertions', 'manifest', 'direction', 'rollback', 'repair_id', 'crc32c'],
349
+ 'journal');
350
+ if (J.schema !== JOURNAL_SCHEMA) bad(`journal.schema 必须是 ${JOURNAL_SCHEMA},得到 ${J.schema}`);
351
+ if (!isUint(J.generation)) bad('journal.generation 必须是非负整数');
352
+ if (J.tx_dir !== `tx-${J.generation}`) bad(`journal.tx_dir 必须是 tx-${J.generation},得到 ${J.tx_dir}`);
353
+ if (!PHASES.includes(J.phase)) bad(`journal.phase 未知取值 ${J.phase}`);
354
+ if (J.items === null || typeof J.items !== 'object' || Array.isArray(J.items)) {
355
+ bad('journal.items 必须是对象');
356
+ }
357
+ for (const [name, it] of Object.entries(J.items)) {
358
+ validateItem(name, it);
359
+ assertStateCombo(name, it);
360
+ }
361
+ if ('adopt_assertions' in J) validateAssertionMap(J.adopt_assertions, 'adopt_assertions', J);
362
+ if ('unadopt_assertions' in J) validateAssertionMap(J.unadopt_assertions, 'unadopt_assertions', J);
363
+ allItemKeys(J); // 三者互不相交
364
+ if ('manifest' in J && J.manifest !== 'durable') bad(`journal.manifest 只允许 "durable"`);
365
+ if ('repair_id' in J && (typeof J.repair_id !== 'string' || J.repair_id === '')) {
366
+ bad('journal.repair_id 必须是非空字符串');
367
+ }
368
+ validateLedgerImage(J.ledger_image);
369
+ validateRollback(J);
370
+ return J;
371
+ }
372
+
373
+ // ── 读写 ─────────────────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * §11 §5:`crc32c` 覆盖范围 = **去掉 `crc32c` 这一个 key 之后**该对象的 canonical
377
+ * 字节(含结尾换行)。
378
+ */
379
+ export function journalCrc(obj) {
380
+ const { crc32c: _drop, ...rest } = obj;
381
+ return crc32cHex(Buffer.from(stringify(rest), 'utf8'));
382
+ }
383
+
384
+ export function writeJournal(path, obj) {
385
+ validateJournal(obj);
386
+ const { crc32c: _drop, ...rest } = obj;
387
+ const withCrc = { ...rest, crc32c: journalCrc(rest) };
388
+ writeAtomic(path, stringify(withCrc));
389
+ return withCrc;
390
+ }
391
+
392
+ export function readJournal(path) {
393
+ const obj = readJsonStrict(path, 'journal');
394
+ const got = obj.crc32c;
395
+ if (typeof got !== 'string') bad(`journal 缺 crc32c:${path}`);
396
+ if (!/^[0-9a-f]{8}$/.test(got)) bad(`journal.crc32c 必须是 8 位定宽小写 hex:${got}`);
397
+ const want = journalCrc(obj);
398
+ if (got !== want) bad(`journal crc32c 不符:${path} 记 ${got},实算 ${want}`);
399
+ return validateJournal(obj);
400
+ }
401
+
402
+ // ── .tmp 残留(§5.4「I/O 失败的统一规则」)──────────────────────────────────
403
+
404
+ /**
405
+ * 🔴 journal 原子写失败留下的 `.tmp`:恢复时一律**忽略并删除**。
406
+ * 权威副本是那个已 rename 到位的文件;`.tmp` 按定义未提交。
407
+ */
408
+ export function sweepTmp(dir) {
409
+ if (!existsSync(dir)) return [];
410
+ const gone = [];
411
+ for (const name of readdirSync(dir)) {
412
+ if (name.endsWith('.tmp')) { rmtreeFsync(join(dir, name)); gone.push(name); }
413
+ }
414
+ return gone;
415
+ }
416
+
417
+ /**
418
+ * 🔴 §5.10 守卫 0″ 的扫描口径:**只扫已提交的规范文件 `journal/<generation>.json`**。
419
+ *
420
+ * 🔴 **本函数是纯读的**(Codex 第三轮 #23):`.tmp` 的清扫由 `recover()` 入口显式做。
421
+ * 检查函数带副作用时,「不变式跑了一遍之后现场就变了」,I7 的结论也就不可信了。
422
+ * @returns {number[]} 升序的 generation 列表
423
+ */
424
+ export function listJournalGenerations(journalDir) {
425
+ if (!existsSync(journalDir)) return [];
426
+ const gens = [];
427
+ for (const name of readdirSync(journalDir)) {
428
+ const m = /^(\d+)\.json$/.exec(name);
429
+ // 🔴 名字不是规范形状的一律忽略而不是猜 —— 但也不删(它可能是人留的证据)。
430
+ if (!m) continue;
431
+ if (m[1].length > 1 && m[1][0] === '0') bad(`journal 文件名有前导零:${name}`);
432
+ gens.push(Number(m[1]));
433
+ }
434
+ return gens.sort((a, b) => a - b);
435
+ }