@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/skills-hub.mjs +26 -0
- package/package.json +44 -0
- package/src/adapters/index.mjs +832 -0
- package/src/artifact.mjs +376 -0
- package/src/atomic-fs.mjs +166 -0
- package/src/attestation.mjs +136 -0
- package/src/canonical-json.mjs +147 -0
- package/src/cli.mjs +208 -0
- package/src/commands/check.mjs +295 -0
- package/src/commands/context.mjs +235 -0
- package/src/commands/install.mjs +430 -0
- package/src/commands/locks.mjs +197 -0
- package/src/commands/output.mjs +127 -0
- package/src/commands/query.mjs +266 -0
- package/src/commands/recover.mjs +438 -0
- package/src/commands/registry.mjs +123 -0
- package/src/commands/resolve.mjs +171 -0
- package/src/commands/snapshot-access.mjs +91 -0
- package/src/commands/sync-lock.mjs +189 -0
- package/src/crc32c.mjs +27 -0
- package/src/exit-codes.mjs +265 -0
- package/src/fault-inject.mjs +379 -0
- package/src/install.mjs +732 -0
- package/src/journal.mjs +435 -0
- package/src/ledger.mjs +671 -0
- package/src/lock.mjs +98 -0
- package/src/lockfile.mjs +0 -0
- package/src/pack.mjs +792 -0
- package/src/packer.mjs +351 -0
- package/src/plan.mjs +519 -0
- package/src/recover.mjs +1345 -0
- package/src/safe-fs.mjs +252 -0
- package/src/sigstore.mjs +480 -0
- package/src/snapshot.mjs +528 -0
- package/src/stats.mjs +59 -0
- package/src/target.mjs +738 -0
- package/src/telemetry.mjs +393 -0
- package/src/tree-digest.mjs +103 -0
- package/src/trust-roots/README.md +31 -0
- package/src/trust-roots/sigstore-public-good.json +126 -0
- package/src/trust.mjs +563 -0
- package/src/untar.mjs +570 -0
- package/src/upload.mjs +268 -0
- package/src/vendor.mjs +465 -0
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// plan —— 计划编译、`ledger_image` 构造、每代 attic manifest、`--from-generation` 的
|
|
2
|
+
// `postimage` 三方比对与 `--only` 闭包。
|
|
3
|
+
//
|
|
4
|
+
// 规格:04-install.md §4.2(未认领同名目录、adopt / unadopt)、§5.2 第 6 步、
|
|
5
|
+
// §5.3(三种 op)、§5.4.1(结构门:禁止 swap 的 old==new)、§5.4.2(ledger_image)、
|
|
6
|
+
// §5.8(每代 manifest 与 reverse_op)、§5.8.1(postimage 的形式化定义与比对)。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 本模块是**纯编译**:不写盘、不调度、不 import install / recover。
|
|
9
|
+
|
|
10
|
+
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { stringify } from './canonical-json.mjs';
|
|
13
|
+
import { treeDigest } from './tree-digest.mjs';
|
|
14
|
+
import { parseSafeRelPath } from './safe-fs.mjs';
|
|
15
|
+
import { Corrupt, bad, isTreeDigest, isUint, assertKeys, assertSafeName } from './journal.mjs';
|
|
16
|
+
import { ATTIC_MANIFEST_SCHEMA } from './ledger.mjs';
|
|
17
|
+
|
|
18
|
+
export { Corrupt };
|
|
19
|
+
|
|
20
|
+
// ── 结构与元数据的严格验明(§4.2「严格验明」的含义)─────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 🔴 **不只是** `geoly-tree-v1` 摘要相等 —— 该摘要**不覆盖空目录与部分元数据**
|
|
24
|
+
* (01-artifacts.md §6.2.1)。还必须过最终落位的结构与元数据约束:
|
|
25
|
+
* 目录、空目录、类型、mode、普通文件 `nlink == 1`。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **诚实边界**:xattr / ACL 在纯 Node 里读不到(无 `listxattr` 绑定),
|
|
28
|
+
* 因此这一条**本实现证明不了**,只能靠 §7 的解包策略层在**入口**处拒绝。
|
|
29
|
+
* 见交付汇报的「还没被证明的性质」。
|
|
30
|
+
*/
|
|
31
|
+
export function strictPayloadCheck(dir, { allowEmptyDirs = false } = {}) {
|
|
32
|
+
const problems = [];
|
|
33
|
+
(function rec(d, rel) {
|
|
34
|
+
let names;
|
|
35
|
+
try { names = readdirSync(d); } catch (e) { problems.push(`${rel || '.'} 不可读:${e.code}`); return; }
|
|
36
|
+
if (names.length === 0 && !allowEmptyDirs && rel !== '') {
|
|
37
|
+
problems.push(`空目录 ${rel}(制品禁止空目录)`);
|
|
38
|
+
}
|
|
39
|
+
for (const name of names.sort()) {
|
|
40
|
+
const abs = join(d, name);
|
|
41
|
+
const r = rel === '' ? name : `${rel}/${name}`;
|
|
42
|
+
const st = lstatSync(abs);
|
|
43
|
+
if (st.isSymbolicLink()) { problems.push(`symlink ${r}`); continue; }
|
|
44
|
+
if (st.isDirectory()) {
|
|
45
|
+
if ((st.mode & 0o777) !== 0o755) problems.push(`目录 mode 必须是 0755:${r}`);
|
|
46
|
+
rec(abs, r);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!st.isFile()) { problems.push(`非普通文件 ${r}`); continue; }
|
|
50
|
+
if (st.nlink !== 1) problems.push(`hardlink(nlink=${st.nlink})${r}`);
|
|
51
|
+
const mode = st.mode & 0o777;
|
|
52
|
+
if (mode !== 0o644 && mode !== 0o755) problems.push(`文件 mode 只允许 0644/0755:${r}`);
|
|
53
|
+
try { parseSafeRelPath(r); } catch (e) { problems.push(`路径不合法 ${r}:${e.message}`); }
|
|
54
|
+
}
|
|
55
|
+
})(dir, '');
|
|
56
|
+
return problems;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** §4.2:严格验明「那个未认领目录 == 目标制品」——摘要 + 结构与元数据,两者缺一不可 */
|
|
60
|
+
export function strictlyMatches(dir, expectDigest) {
|
|
61
|
+
if (!existsSync(dir)) return { ok: false, why: '目录不存在' };
|
|
62
|
+
let d;
|
|
63
|
+
try { d = treeDigest(dir); } catch (e) { return { ok: false, why: `摘要算不出来:${e.message}` }; }
|
|
64
|
+
if (d !== expectDigest) return { ok: false, why: `摘要 ${d} != 期望 ${expectDigest}` };
|
|
65
|
+
const problems = strictPayloadCheck(dir);
|
|
66
|
+
if (problems.length) return { ok: false, why: `结构/元数据不符:${problems.join('; ')}` };
|
|
67
|
+
return { ok: true, digest: d };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 计划编译 ─────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把「要装什么 / 要退役什么」编译成一份可写进 journal 的计划。
|
|
74
|
+
*
|
|
75
|
+
* @param {object} o
|
|
76
|
+
* @param {string} o.target 物理 target 目录
|
|
77
|
+
* @param {object} o.ledger 当前账本
|
|
78
|
+
* @param {number} o.generation 本次取到的 generation(水位已推进)
|
|
79
|
+
* @param {Array} [o.install] [{name, artifact, tree_digest, srcDir, snapshot, requested_by, installed_at}]
|
|
80
|
+
* @param {Array} [o.retire] [name]
|
|
81
|
+
* @param {object} [o.roots] 本次要写入的 root 记录 {key: record}
|
|
82
|
+
* @param {Array} [o.removeRoots] 本次要删除的 root key
|
|
83
|
+
* @param {Set} [o.replace] `--replace` 点名的未认领目录
|
|
84
|
+
* @param {Array} [o.unadopt] [{name, artifact, tree_digest}]
|
|
85
|
+
* @param {object|null} [o.frozenAttic] 整张 map(不传 = 不改)
|
|
86
|
+
* @param {Array} [o.auditAppend] 本次要追加的 audit 事件
|
|
87
|
+
*/
|
|
88
|
+
export function derivePlan(o) {
|
|
89
|
+
const {
|
|
90
|
+
target, ledger, generation,
|
|
91
|
+
install = [], retire = [], roots = {}, removeRoots = [],
|
|
92
|
+
replace = new Set(), unadopt = [], frozenAttic, auditAppend = [], ledgerExisted,
|
|
93
|
+
} = o;
|
|
94
|
+
if (!isUint(generation)) bad('derivePlan:generation 必须是非负整数');
|
|
95
|
+
// 🔴 §5.4.2:`ledger_existed` 表示的是「**本次事务开始之前**」账本存不存在,
|
|
96
|
+
// 不因本次写出的 bootstrap 骨架而改变。默认成 true 会让首次安装的 rollback
|
|
97
|
+
// 把一个本该删掉的空骨架当成「原本就有的账本」保留下来。
|
|
98
|
+
// 所以它**必须显式给**(Codex 第二轮 #17)。
|
|
99
|
+
if (typeof ledgerExisted !== 'boolean') {
|
|
100
|
+
bad('derivePlan:ledgerExisted 必须显式给出(事务开始**之前**账本是否存在)');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const items = {};
|
|
104
|
+
const adopt = {};
|
|
105
|
+
const unad = {};
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const claim = (name) => {
|
|
108
|
+
// §5.4 通用规则:**同一事务内计划项的 `name` 必须唯一**(结构门 + 运行时都查)
|
|
109
|
+
if (seen.has(name)) bad(`计划里 name 重复:${name}`);
|
|
110
|
+
parseSafeRelPath(name); // 目录名必须过路径 grammar
|
|
111
|
+
seen.add(name);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const req of install) {
|
|
115
|
+
claim(req.name);
|
|
116
|
+
const T = join(target, req.name);
|
|
117
|
+
if (!isTreeDigest(req.tree_digest)) bad(`install[${req.name}].tree_digest 形式非法`);
|
|
118
|
+
if (!existsSync(T)) {
|
|
119
|
+
items[req.name] = {
|
|
120
|
+
op: 'install-new', had_old: false, state: 'planned', new_digest: req.tree_digest,
|
|
121
|
+
};
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const claimed = Object.hasOwn(ledger.entries, req.name);
|
|
125
|
+
if (!claimed && !replace.has(req.name)) {
|
|
126
|
+
// §4.2:默认阻断。出路只有 `--replace <name>` 或用户自己移走。🔴 不提供泛化 --force。
|
|
127
|
+
bad(`${req.name} 是未被账本认领的同名目录:默认阻断。出路只有 --replace ${req.name} 或自行移走`);
|
|
128
|
+
}
|
|
129
|
+
if (!claimed) {
|
|
130
|
+
// 🔴 §4.2 的「逐字节相同」分支:严格验明相等 → 不构造物理 swap(那会撞上
|
|
131
|
+
// 「禁止 old_digest == new_digest」而被拒),改为 ledger-only 的受管化。
|
|
132
|
+
const m = strictlyMatches(T, req.tree_digest);
|
|
133
|
+
if (m.ok) {
|
|
134
|
+
adopt[req.name] = { artifact: req.artifact, state: 'ok', tree_digest: req.tree_digest };
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// §5.6 前提 4:未认领旧目录不满足载荷规则 → **预检直接拒绝 --replace**
|
|
138
|
+
const problems = strictPayloadCheck(T);
|
|
139
|
+
if (problems.length) {
|
|
140
|
+
bad(`--replace ${req.name} 被拒:旧树不满足 §01 的载荷规则 —— ${problems.join('; ')}。`
|
|
141
|
+
+ '不为它定义第二套 retired-tree 格式,请自行移走');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// §5.6:`old_digest` 的来源必须是「首次 rename 之前的实测值」
|
|
145
|
+
const oldDigest = treeDigest(T);
|
|
146
|
+
if (oldDigest === req.tree_digest) {
|
|
147
|
+
// 已认领且逐字节相同:同样不构造物理 swap,走受管化断言(幂等,账本对齐即可)
|
|
148
|
+
adopt[req.name] = { artifact: req.artifact, state: 'ok', tree_digest: req.tree_digest };
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
items[req.name] = {
|
|
152
|
+
op: 'swap', had_old: true, state: 'planned',
|
|
153
|
+
old_digest: oldDigest, new_digest: req.tree_digest,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const name of retire) {
|
|
158
|
+
claim(name);
|
|
159
|
+
const T = join(target, name);
|
|
160
|
+
if (!existsSync(T)) bad(`retire-only ${name}:target 上没有这个目录`);
|
|
161
|
+
items[name] = { op: 'retire-only', had_old: true, state: 'planned', old_digest: treeDigest(T) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const u of unadopt) {
|
|
165
|
+
claim(u.name);
|
|
166
|
+
if (!isTreeDigest(u.tree_digest)) bad(`unadopt[${u.name}].tree_digest 形式非法`);
|
|
167
|
+
unad[u.name] = { artifact: u.artifact, state: 'ok', tree_digest: u.tree_digest };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ledgerImage = buildLedgerImage({
|
|
171
|
+
ledger, generation, items, adopt, unadopt: unad,
|
|
172
|
+
install, retire, roots, removeRoots, frozenAttic, auditAppend, ledgerExisted,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const plan = {
|
|
176
|
+
generation,
|
|
177
|
+
tx_dir: `tx-${generation}`,
|
|
178
|
+
items,
|
|
179
|
+
ledger_image: ledgerImage,
|
|
180
|
+
// srcDir 不进 journal(它是本次运行的输入,不是持久状态)
|
|
181
|
+
sources: Object.fromEntries(install.map((r) => [r.name, r.srcDir])),
|
|
182
|
+
};
|
|
183
|
+
// 🔴 §11:没有对应逻辑项时**整个字段缺席**(不写 {},也不写 null)
|
|
184
|
+
if (Object.keys(adopt).length) plan.adopt_assertions = adopt;
|
|
185
|
+
if (Object.keys(unad).length) plan.unadopt_assertions = unad;
|
|
186
|
+
return plan;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* §5.4.2 `ledger_image`:`{pre, post}` 两份**只含本次会增/改/删的键**的镜像
|
|
191
|
+
* + `ledger_existed` 哨兵。「原本不存在」或「本次删除」用 `null` 哨兵。
|
|
192
|
+
*
|
|
193
|
+
* 🔴 覆盖面必须含**新增 entry、其对应 root、`requested_by` 边、generation** 等
|
|
194
|
+
* 全部实际变化 —— 只写 entry 会留下未定义或悬挂的 root 关系。
|
|
195
|
+
*/
|
|
196
|
+
export function buildLedgerImage(o) {
|
|
197
|
+
const {
|
|
198
|
+
ledger, generation, items, adopt, unadopt,
|
|
199
|
+
install, retire, roots, removeRoots, frozenAttic, auditAppend, ledgerExisted,
|
|
200
|
+
} = o;
|
|
201
|
+
const touchedEntries = new Set([
|
|
202
|
+
...Object.keys(items), ...Object.keys(adopt), ...Object.keys(unadopt),
|
|
203
|
+
]);
|
|
204
|
+
const touchedRoots = new Set([...Object.keys(roots), ...removeRoots]);
|
|
205
|
+
|
|
206
|
+
const byName = new Map(install.map((r) => [r.name, r]));
|
|
207
|
+
const retiring = new Set(retire);
|
|
208
|
+
|
|
209
|
+
// 新的 entry 值
|
|
210
|
+
const nextEntries = {};
|
|
211
|
+
for (const name of touchedEntries) {
|
|
212
|
+
if (retiring.has(name) || Object.hasOwn(unadopt, name)) { nextEntries[name] = null; continue; }
|
|
213
|
+
const req = byName.get(name);
|
|
214
|
+
if (!req) {
|
|
215
|
+
// 只在 items 里而没有 install 记录 —— 那只能是 retire-only,已在上面处理
|
|
216
|
+
nextEntries[name] = null;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const requestedBy = [...new Set(req.requested_by ?? [])]
|
|
220
|
+
.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
221
|
+
nextEntries[name] = {
|
|
222
|
+
artifact: req.artifact,
|
|
223
|
+
generation,
|
|
224
|
+
installed_at: req.installed_at,
|
|
225
|
+
requested_by: requestedBy,
|
|
226
|
+
snapshot: req.snapshot,
|
|
227
|
+
state: 'ok',
|
|
228
|
+
tree_digest: req.tree_digest,
|
|
229
|
+
};
|
|
230
|
+
for (const rk of requestedBy) touchedRoots.add(rk);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 🔴 requested_by 边的另一半:被删掉的 entry 曾指向的 root 也算「本次变化」
|
|
234
|
+
for (const name of touchedEntries) {
|
|
235
|
+
const old = ledger.entries[name];
|
|
236
|
+
if (old) for (const rk of old.requested_by) touchedRoots.add(rk);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pre = { entries: {}, roots: {}, last_applied_generation: ledger.last_applied_generation };
|
|
240
|
+
const post = { entries: {}, roots: {}, last_applied_generation: generation };
|
|
241
|
+
for (const name of touchedEntries) {
|
|
242
|
+
pre.entries[name] = ledger.entries[name] ?? null;
|
|
243
|
+
post.entries[name] = nextEntries[name] ?? null;
|
|
244
|
+
}
|
|
245
|
+
const removing = new Set(removeRoots);
|
|
246
|
+
for (const key of touchedRoots) {
|
|
247
|
+
pre.roots[key] = ledger.roots[key] ?? null;
|
|
248
|
+
if (removing.has(key)) post.roots[key] = null;
|
|
249
|
+
else if (Object.hasOwn(roots, key)) post.roots[key] = roots[key];
|
|
250
|
+
else post.roots[key] = ledger.roots[key] ?? null;
|
|
251
|
+
}
|
|
252
|
+
if (frozenAttic !== undefined) {
|
|
253
|
+
// 🔴 `frozen_attic` 在镜像覆盖范围内,且**按整张 map 存取**(不是逐 label patch)
|
|
254
|
+
pre.frozen_attic = ledger.frozen_attic ?? null;
|
|
255
|
+
post.frozen_attic = frozenAttic;
|
|
256
|
+
}
|
|
257
|
+
if (auditAppend && auditAppend.length) post.audit_append = auditAppend;
|
|
258
|
+
|
|
259
|
+
if (typeof ledgerExisted !== 'boolean') bad('buildLedgerImage:ledgerExisted 必须显式给出');
|
|
260
|
+
return { ledger_existed: ledgerExisted, pre, post };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── 每代 manifest(§5.8)─────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/** 🔴 复位时该项要执行的**正向 op**,不是「一律按 swap」 */
|
|
266
|
+
export const REVERSE_OP = {
|
|
267
|
+
swap: 'swap',
|
|
268
|
+
'retire-only': 'install-new',
|
|
269
|
+
'install-new': 'retire-only',
|
|
270
|
+
adopt: 'unadopt',
|
|
271
|
+
unadopt: 'adopt',
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 构造 `attic/<N>/manifest.json`。
|
|
276
|
+
*
|
|
277
|
+
* @param {object} J 本代 journal(已到 cleanup_pending)
|
|
278
|
+
* @param {object} ledgerAfter 本代收尾时的账本(用于算 postimage 基线)
|
|
279
|
+
* @param {string} target 物理 target(digests 要实测)
|
|
280
|
+
*/
|
|
281
|
+
export function buildManifest(J, ledgerAfter, target, { createdAt }) {
|
|
282
|
+
const items = {};
|
|
283
|
+
for (const [name, it] of Object.entries(J.items)) {
|
|
284
|
+
items[name] = {
|
|
285
|
+
old_digest: it.op === 'install-new' ? null : it.old_digest,
|
|
286
|
+
op: it.op,
|
|
287
|
+
reverse_op: REVERSE_OP[it.op],
|
|
288
|
+
tar: it.op === 'install-new' ? null : `${name}.tar`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// 🔴 adopt / unadopt 不参与 A / C 阶段,但**只在 B 阶段进 manifest**
|
|
292
|
+
for (const name of Object.keys(J.adopt_assertions ?? {})) {
|
|
293
|
+
items[name] = { old_digest: null, op: 'adopt', reverse_op: 'unadopt', tar: null };
|
|
294
|
+
}
|
|
295
|
+
for (const name of Object.keys(J.unadopt_assertions ?? {})) {
|
|
296
|
+
items[name] = { old_digest: null, op: 'unadopt', reverse_op: 'adopt', tar: null };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const delta = ledgerDeltaFromImage(J.ledger_image);
|
|
300
|
+
const postimage = buildPostimage(J, ledgerAfter, target, delta);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
schema: ATTIC_MANIFEST_SCHEMA,
|
|
304
|
+
created_at: createdAt,
|
|
305
|
+
generation: J.generation,
|
|
306
|
+
items,
|
|
307
|
+
ledger_delta: delta,
|
|
308
|
+
postimage,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 🔴 `ledger_delta` 是**增量**不是全量:只记本代动过的键(`null` = 复位后应不存在)。
|
|
314
|
+
* 它就是 `ledger_image.pre` —— 「复位后应有的值」。
|
|
315
|
+
*/
|
|
316
|
+
export function ledgerDeltaFromImage(image) {
|
|
317
|
+
const d = {
|
|
318
|
+
entries: { ...image.pre.entries },
|
|
319
|
+
roots: { ...image.pre.roots },
|
|
320
|
+
};
|
|
321
|
+
// 🔴 frozen_attic 纳入 delta(v11 漏了);🔴 manifest 一律不含 audit 相关字段
|
|
322
|
+
if ('frozen_attic' in image.pre) d.frozen_attic = image.pre.frozen_attic;
|
|
323
|
+
return d;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* §5.8.1 的形式化定义。基线**固定在「第 N 代收尾那一刻」**。
|
|
328
|
+
*
|
|
329
|
+
* ```
|
|
330
|
+
* E_N = names(本代 items) ∪ keys(本代 ledger_delta.entries)
|
|
331
|
+
* R_N = keys(本代 ledger_delta.roots) ∪ **当时**指向 E_N 的全部 root
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
export function buildPostimage(J, ledgerAfter, target, delta) {
|
|
335
|
+
const E = new Set([
|
|
336
|
+
...Object.keys(J.items),
|
|
337
|
+
...Object.keys(J.adopt_assertions ?? {}),
|
|
338
|
+
...Object.keys(J.unadopt_assertions ?? {}),
|
|
339
|
+
...Object.keys(delta.entries),
|
|
340
|
+
]);
|
|
341
|
+
const R = new Set(Object.keys(delta.roots));
|
|
342
|
+
for (const name of E) {
|
|
343
|
+
const e = ledgerAfter.entries[name];
|
|
344
|
+
if (e) for (const rk of e.requested_by) R.add(rk);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const entries = {}, roots = {}, inEdges = {}, outEdges = {}, digests = {};
|
|
348
|
+
for (const name of [...E].sort()) {
|
|
349
|
+
const e = ledgerAfter.entries[name] ?? null;
|
|
350
|
+
entries[name] = e;
|
|
351
|
+
// 🔴 该 entry **当时的完整入边集**
|
|
352
|
+
inEdges[name] = e ? [...e.requested_by] : [];
|
|
353
|
+
const dir = join(target, name);
|
|
354
|
+
digests[name] = existsSync(dir)
|
|
355
|
+
? { digest: treeDigest(dir), present: true }
|
|
356
|
+
: { present: false };
|
|
357
|
+
}
|
|
358
|
+
for (const key of [...R].sort()) {
|
|
359
|
+
roots[key] = ledgerAfter.roots[key] ?? null;
|
|
360
|
+
outEdges[key] = Object.entries(ledgerAfter.entries)
|
|
361
|
+
.filter(([, e]) => e.requested_by.includes(key))
|
|
362
|
+
.map(([n]) => n)
|
|
363
|
+
.sort();
|
|
364
|
+
}
|
|
365
|
+
const pi = {
|
|
366
|
+
digests, entries, in_edges: inEdges, out_edges: outEdges, roots,
|
|
367
|
+
};
|
|
368
|
+
// 🔴 整张 map 的全量值(不是「相关 label」)
|
|
369
|
+
if ('frozen_attic' in delta) pi.frozen_attic = ledgerAfter.frozen_attic ?? null;
|
|
370
|
+
return pi;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function validateManifest(M) {
|
|
374
|
+
assertKeys(M, ['schema', 'created_at', 'generation', 'items', 'ledger_delta', 'postimage'], [], 'attic-manifest');
|
|
375
|
+
if (M.schema !== ATTIC_MANIFEST_SCHEMA) bad(`attic-manifest.schema 必须是 ${ATTIC_MANIFEST_SCHEMA}`);
|
|
376
|
+
if (!isUint(M.generation)) bad('attic-manifest.generation 必须是非负整数');
|
|
377
|
+
const OPS = ['swap', 'install-new', 'retire-only', 'adopt', 'unadopt'];
|
|
378
|
+
for (const [name, it] of Object.entries(M.items)) {
|
|
379
|
+
const where = `attic-manifest.items[${name}]`;
|
|
380
|
+
assertSafeName(name, 'attic-manifest.items 的键');
|
|
381
|
+
assertKeys(it, ['op', 'tar', 'old_digest', 'reverse_op'], [], where);
|
|
382
|
+
// 🔴 §11:`op` 与 `reverse_op` **枚举相同**(v42 两侧不一致会让规范自噬)
|
|
383
|
+
if (!OPS.includes(it.op)) bad(`${where}.op 未知取值 ${it.op}`);
|
|
384
|
+
if (!OPS.includes(it.reverse_op)) bad(`${where}.reverse_op 未知取值 ${it.reverse_op}`);
|
|
385
|
+
if (it.reverse_op !== REVERSE_OP[it.op]) bad(`${where}.reverse_op 与 op 不互逆`);
|
|
386
|
+
const nullable = ['install-new', 'adopt', 'unadopt'].includes(it.op);
|
|
387
|
+
if (nullable) {
|
|
388
|
+
if (it.tar !== null || it.old_digest !== null) bad(`${where}:op=${it.op} 时 tar 与 old_digest 必须是 null`);
|
|
389
|
+
} else {
|
|
390
|
+
if (it.tar !== `${name}.tar`) bad(`${where}.tar 必须是 ${name}.tar`);
|
|
391
|
+
if (!isTreeDigest(it.old_digest)) bad(`${where}.old_digest 必须是树摘要`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
assertKeys(M.ledger_delta, ['entries', 'roots'], ['frozen_attic'], 'attic-manifest.ledger_delta');
|
|
395
|
+
assertKeys(M.postimage, ['digests', 'entries', 'in_edges', 'out_edges', 'roots'], ['frozen_attic'],
|
|
396
|
+
'attic-manifest.postimage');
|
|
397
|
+
for (const [n, d] of Object.entries(M.postimage.digests)) {
|
|
398
|
+
const where = `attic-manifest.postimage.digests[${n}]`;
|
|
399
|
+
assertSafeName(n, 'attic-manifest.postimage.digests 的键');
|
|
400
|
+
// 🔴 显式 tagged 形式,不用裸 null ——「目标应缺席」是一个**正面断言**
|
|
401
|
+
if (d.present === true) assertKeys(d, ['present', 'digest'], [], where);
|
|
402
|
+
else if (d.present === false) assertKeys(d, ['present'], [], where);
|
|
403
|
+
else bad(`${where}.present 必须是布尔`);
|
|
404
|
+
if (d.present && !isTreeDigest(d.digest)) bad(`${where}.digest 必须是树摘要`);
|
|
405
|
+
}
|
|
406
|
+
return M;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── `--from-generation` 的三方比对与 `--only` 闭包(§5.8 / §5.8.1)───────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 🔴 **绝不把 delta 盲 patch 到当前账本上。** 先把**当前**账本按同样的键集算一遍,
|
|
413
|
+
* 逐项相等才放行。后来新增的 `P → a` 会以「`a` 的当前入边集比 `in_edges[a]`
|
|
414
|
+
* 多了一条 `P`」的形式被抓住。
|
|
415
|
+
*
|
|
416
|
+
* @returns {{conflicts: string[]}}
|
|
417
|
+
*/
|
|
418
|
+
export function comparePostimage(M, currentLedger, target, { only } = {}) {
|
|
419
|
+
const conflicts = [];
|
|
420
|
+
const sel = selectClosure(M, { only });
|
|
421
|
+
const eq = (a, b) => stringify(a ?? null) === stringify(b ?? null);
|
|
422
|
+
|
|
423
|
+
for (const name of sel.entries) {
|
|
424
|
+
if (!eq(currentLedger.entries[name] ?? null, M.postimage.entries[name] ?? null)) {
|
|
425
|
+
conflicts.push(`entries[${name}] 与第 ${M.generation} 代收尾时不同`);
|
|
426
|
+
}
|
|
427
|
+
const want = M.postimage.in_edges[name] ?? [];
|
|
428
|
+
const got = currentLedger.entries[name]?.requested_by ?? [];
|
|
429
|
+
if (!eq([...want].sort(), [...got].sort())) {
|
|
430
|
+
conflicts.push(`in_edges[${name}] 不同:当时 ${JSON.stringify(want)},现在 ${JSON.stringify(got)}`);
|
|
431
|
+
}
|
|
432
|
+
const d = M.postimage.digests[name];
|
|
433
|
+
const dir = join(target, name);
|
|
434
|
+
if (d.present) {
|
|
435
|
+
if (!existsSync(dir)) conflicts.push(`目标树 ${name} 当时存在,现在缺席`);
|
|
436
|
+
else if (treeDigest(dir) !== d.digest) conflicts.push(`目标树 ${name} 摘要与当时不同`);
|
|
437
|
+
} else if (existsSync(dir)) {
|
|
438
|
+
conflicts.push(`目标树 ${name} 当时应缺席,现在存在`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const key of sel.roots) {
|
|
442
|
+
if (!eq(currentLedger.roots[key] ?? null, M.postimage.roots[key] ?? null)) {
|
|
443
|
+
conflicts.push(`roots[${key}] 与第 ${M.generation} 代收尾时不同`);
|
|
444
|
+
}
|
|
445
|
+
const want = M.postimage.out_edges[key] ?? [];
|
|
446
|
+
const got = Object.entries(currentLedger.entries)
|
|
447
|
+
.filter(([, e]) => e.requested_by.includes(key)).map(([n]) => n).sort();
|
|
448
|
+
if (!eq([...want].sort(), got)) {
|
|
449
|
+
conflicts.push(`out_edges[${key}] 不同:当时 ${JSON.stringify(want)},现在 ${JSON.stringify(got)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if ('frozen_attic' in M.postimage) {
|
|
453
|
+
if (!eq(currentLedger.frozen_attic ?? null, M.postimage.frozen_attic ?? null)) {
|
|
454
|
+
conflicts.push('frozen_attic 与当时不同');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return { conflicts, selection: sel };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* §5.8.1 的 `--only` 闭包。定义在**第 N 代收尾时的 `E_N` / `R_N` 二部图**上。
|
|
462
|
+
* 🔴 CLI **不自动扩张**到整个连通分量,而是**拒绝并把完整分量列出来**。
|
|
463
|
+
*/
|
|
464
|
+
export function selectClosure(M, { only } = {}) {
|
|
465
|
+
const E = new Set(Object.keys(M.postimage.entries));
|
|
466
|
+
const R = new Set(Object.keys(M.postimage.roots));
|
|
467
|
+
if (!only || only.length === 0) {
|
|
468
|
+
return {
|
|
469
|
+
entries: [...E].sort(),
|
|
470
|
+
roots: [...R].sort(),
|
|
471
|
+
items: Object.keys(M.items).sort(),
|
|
472
|
+
delta: M.ledger_delta,
|
|
473
|
+
whole: true,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const seeds = [...new Set(only)];
|
|
477
|
+
for (const s of seeds) if (!E.has(s)) bad(`--only ${s} 不在第 ${M.generation} 代的 E_N 里`);
|
|
478
|
+
// 🔴 只要本代 ledger_delta.frozen_attic 有变化,就禁止部分复位(target 级、切不开)
|
|
479
|
+
if ('frozen_attic' in M.ledger_delta) {
|
|
480
|
+
bad(`第 ${M.generation} 代改动了 frozen_attic(target 级、切不开):禁止部分复位,请选择整代`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const inE = new Set(seeds);
|
|
484
|
+
const inR = new Set();
|
|
485
|
+
for (;;) {
|
|
486
|
+
let grew = false;
|
|
487
|
+
for (const n of [...inE]) {
|
|
488
|
+
for (const rk of M.postimage.in_edges[n] ?? []) if (!inR.has(rk)) { inR.add(rk); grew = true; }
|
|
489
|
+
}
|
|
490
|
+
for (const rk of [...inR]) {
|
|
491
|
+
for (const n of M.postimage.out_edges[rk] ?? []) if (!inE.has(n)) { inE.add(n); grew = true; }
|
|
492
|
+
}
|
|
493
|
+
if (!grew) break;
|
|
494
|
+
}
|
|
495
|
+
// 🔴 闭包不完整(某个 root 的 out_edges 有成员落在 E_N 之外)→ 拒绝
|
|
496
|
+
for (const rk of inR) {
|
|
497
|
+
for (const n of M.postimage.out_edges[rk] ?? []) {
|
|
498
|
+
if (!E.has(n)) bad(`root ${rk} 的成员 ${n} 落在第 ${M.generation} 代的 E_N 之外,闭包无法闭合`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const selected = [...inE].sort();
|
|
502
|
+
const missing = selected.filter((n) => !seeds.includes(n));
|
|
503
|
+
if (missing.length) {
|
|
504
|
+
bad(`--only 的闭包不完整:还要一起选 ${missing.join(', ')}`
|
|
505
|
+
+ `(完整分量:${selected.join(', ')})`);
|
|
506
|
+
}
|
|
507
|
+
const selRoots = [...inR].sort();
|
|
508
|
+
// 🔴 `--only` 必须**同时过滤 delta**
|
|
509
|
+
const delta = { entries: {}, roots: {} };
|
|
510
|
+
for (const k of selected) if (k in M.ledger_delta.entries) delta.entries[k] = M.ledger_delta.entries[k];
|
|
511
|
+
for (const k of selRoots) if (k in M.ledger_delta.roots) delta.roots[k] = M.ledger_delta.roots[k];
|
|
512
|
+
return {
|
|
513
|
+
entries: selected,
|
|
514
|
+
roots: selRoots,
|
|
515
|
+
items: Object.keys(M.items).filter((n) => inE.has(n)).sort(),
|
|
516
|
+
delta,
|
|
517
|
+
whole: false,
|
|
518
|
+
};
|
|
519
|
+
}
|