@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/pack.mjs
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// pack 模型 —— 规范:03-packs.md(权威)、01-artifacts.md §3/§5.2、
|
|
2
|
+
// 04-install.md §4/§4.1/§8.1、05-lifecycle.md §5.1。
|
|
3
|
+
//
|
|
4
|
+
// 本模块是**纯函数**层:不碰磁盘、不碰网络、不碰账本文件。它回答四类问题:
|
|
5
|
+
// §2 这份 pack.json 合法吗(成员锁定、role、conflicts 形态、contract_paths)
|
|
6
|
+
// §3 声明 compatible 站得住吗(契约文件零差异门 + D8 的绕过面护栏)
|
|
7
|
+
// §4 这个 pack 现在能装吗、装出哪些成员、refcount 怎么加减、升级差集是什么
|
|
8
|
+
// §5 yank 闭包:它现在是 published 还是 degraded,被谁拖累
|
|
9
|
+
//
|
|
10
|
+
// 🔴 与 `artifact.mjs` 的分工:`assertManifestBinding()` 做的是 §5.3 的
|
|
11
|
+
// **ArtifactId 绑定**,顺带对 pack.json 做了一个**子集**校验(schema、键集、
|
|
12
|
+
// 成员 id 是精确版本、tree_digest 形状)。本模块做的是**全量语义**校验。
|
|
13
|
+
// 两处存在同一份文档的两个校验器 —— 这正是 R-11 反复出现的形状。
|
|
14
|
+
// 约束方向写死在 `test/pack.test.mjs` 的属性测试里:
|
|
15
|
+
// **凡 `validatePackManifest()` 接受的 doc,`assertManifestBinding()` 也必须接受。**
|
|
16
|
+
// (反向不成立,也不应成立:本模块严格更强。)
|
|
17
|
+
import { WireError, assertExactKeys, assertString, assertStringArray, assertUint, assertTreeDigest } from './trust.mjs';
|
|
18
|
+
import { parseSemver, compareSemver } from './snapshot.mjs';
|
|
19
|
+
import { PACK_MANIFEST_SCHEMA } from './artifact.mjs';
|
|
20
|
+
|
|
21
|
+
export { PACK_MANIFEST_SCHEMA };
|
|
22
|
+
|
|
23
|
+
const bad = (code, msg) => { throw new WireError(code, msg); };
|
|
24
|
+
|
|
25
|
+
// ── ArtifactId(01-artifacts.md §3) ───────────────────────────────────────
|
|
26
|
+
// ⚠️ 这两条 grammar 与 `snapshot.mjs` 里的 RE_NAMESPACE / RE_NAME 是同一份规格,
|
|
27
|
+
// 但那边没有导出。重复定义即是分叉风险,因此 test/pack.test.mjs 里有一条
|
|
28
|
+
// 交叉测试:同一批候选 ns/name,`parseArtifactId` 与 `parseSnapshot` 的判定必须一致。
|
|
29
|
+
const RE_NAMESPACE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
30
|
+
const RE_NAME = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
31
|
+
export const KINDS = Object.freeze(['skill', 'pack']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 解析 `<kind>:<namespace>/<name>@<version>`。
|
|
35
|
+
* 🔴 **不接受 range**:`^1.2`、`~1.2`、`*`、空格一律拒 —— range 意味着
|
|
36
|
+
* 「装的时候才知道装到什么」,那样 pack 的树摘要就不代表一次可复现的安装(§2)。
|
|
37
|
+
*/
|
|
38
|
+
export function parseArtifactId(id, where = 'artifact id') {
|
|
39
|
+
assertString(id, where);
|
|
40
|
+
const at = id.lastIndexOf('@');
|
|
41
|
+
const colon = id.indexOf(':');
|
|
42
|
+
if (colon === -1 || at === -1 || at < colon) bad('E_ARTIFACT_ID', `${where} 不是 <kind>:<ns>/<name>@<version>:${id}`);
|
|
43
|
+
const kind = id.slice(0, colon);
|
|
44
|
+
const rest = id.slice(colon + 1, at);
|
|
45
|
+
const version = id.slice(at + 1);
|
|
46
|
+
if (!KINDS.includes(kind)) bad('E_ARTIFACT_ID', `${where}.kind 只能是 skill / pack,得到 ${JSON.stringify(kind)}`);
|
|
47
|
+
const slash = rest.indexOf('/');
|
|
48
|
+
if (slash === -1 || rest.indexOf('/', slash + 1) !== -1) bad('E_ARTIFACT_ID', `${where} 的 <ns>/<name> 部分不合法:${id}`);
|
|
49
|
+
const namespace = rest.slice(0, slash);
|
|
50
|
+
const name = rest.slice(slash + 1);
|
|
51
|
+
if (!RE_NAMESPACE.test(namespace)) bad('E_NAMESPACE', `${where}.namespace 不合 grammar:${JSON.stringify(namespace)}`);
|
|
52
|
+
if (!RE_NAME.test(name)) bad('E_NAME', `${where}.name 不合 grammar:${JSON.stringify(name)}`);
|
|
53
|
+
const semver = parseSemver(version, `${where}.version`);
|
|
54
|
+
return { kind, namespace, name, version, semver, id };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatArtifactId({ kind, namespace, name, version }) {
|
|
58
|
+
return `${kind}:${namespace}/${name}@${version}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── root key grammar(04-install.md §8.1) ──────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ```
|
|
65
|
+
* root-key := "pack:" <ns> "/" <name> "@" <version>
|
|
66
|
+
* | "direct:" <kind> ":" <ns> "/" <name> "@" <version>
|
|
67
|
+
* | "all@snapshot:" <N>
|
|
68
|
+
* ```
|
|
69
|
+
* 🔴 `<N>` 是**纯十进制、无前导零**:R-11 记着「lockfile 读侧接受 `all@snapshot:01`
|
|
70
|
+
* 这类别名」。同一个 root 有两种写法,唯一性与排序就都不成立了。
|
|
71
|
+
*
|
|
72
|
+
* 🔴 R-11 的另一半:`validateRoot()` 不校验 root key 的 grammar,`../escape`
|
|
73
|
+
* 能当 root key 被接受。本函数是那道缺失的门;接线由上层做(本模块不改 ledger.mjs)。
|
|
74
|
+
*/
|
|
75
|
+
export function parseRootKey(key, where = 'root key') {
|
|
76
|
+
assertString(key, where);
|
|
77
|
+
if (key.startsWith('all@snapshot:')) {
|
|
78
|
+
const n = key.slice('all@snapshot:'.length);
|
|
79
|
+
if (!/^(0|[1-9]\d*)$/.test(n)) bad('E_ROOT_KEY', `${where}:all@snapshot 的 N 必须是无前导零的十进制整数,得到 ${JSON.stringify(n)}`);
|
|
80
|
+
if (!Number.isSafeInteger(Number(n))) bad('E_ROOT_KEY', `${where}:snapshot 号超出安全整数范围`);
|
|
81
|
+
return { kind: 'all', snapshot: Number(n), key };
|
|
82
|
+
}
|
|
83
|
+
if (key.startsWith('direct:')) {
|
|
84
|
+
const a = parseArtifactId(key.slice('direct:'.length), `${where}.artifact`);
|
|
85
|
+
return { kind: 'direct', artifact: a, key };
|
|
86
|
+
}
|
|
87
|
+
if (key.startsWith('pack:')) {
|
|
88
|
+
const a = parseArtifactId(key, `${where}.artifact`);
|
|
89
|
+
if (a.kind !== 'pack') bad('E_ROOT_KEY', `${where}:pack root 的 artifact 必须是 pack:,得到 ${a.kind}`);
|
|
90
|
+
return { kind: 'pack', artifact: a, key };
|
|
91
|
+
}
|
|
92
|
+
bad('E_ROOT_KEY', `${where} 不合 grammar(pack: / direct: / all@snapshot:):${JSON.stringify(key)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── conflicts 形态(§2.3) ──────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 只支持三种,**不支持正则**:
|
|
99
|
+
* ① 精确 ArtifactId `skill:geoly/foo@1.0.0`
|
|
100
|
+
* ② `<kind>:<ns>/<name>`(任意版本)
|
|
101
|
+
* ③ `<kind>:<*>/<name>`(任意 namespace;`<*>` 处必须正好是一个星号)
|
|
102
|
+
* 🔴 星号只能整段出现在 namespace 位。部分通配(`skill:ge*` 开头)、
|
|
103
|
+
* namespace 与 name 同时通配、只有 `skill:*` 而没有 name —— 一律拒。
|
|
104
|
+
* 规范没写的组合不给「合理默认」。
|
|
105
|
+
*/
|
|
106
|
+
export function parseConflictPattern(p, where = 'conflicts[]') {
|
|
107
|
+
assertString(p, where);
|
|
108
|
+
const colon = p.indexOf(':');
|
|
109
|
+
if (colon === -1) bad('E_CONFLICT_FORM', `${where} 缺少 <kind>: 前缀:${p}`);
|
|
110
|
+
const kind = p.slice(0, colon);
|
|
111
|
+
if (!KINDS.includes(kind)) bad('E_CONFLICT_FORM', `${where}.kind 只能是 skill / pack:${p}`);
|
|
112
|
+
const rest = p.slice(colon + 1);
|
|
113
|
+
if (rest.includes('@')) {
|
|
114
|
+
const a = parseArtifactId(p, where);
|
|
115
|
+
return { form: 'exact', kind, namespace: a.namespace, name: a.name, version: a.version, raw: p };
|
|
116
|
+
}
|
|
117
|
+
const slash = rest.indexOf('/');
|
|
118
|
+
if (slash === -1 || rest.indexOf('/', slash + 1) !== -1) bad('E_CONFLICT_FORM', `${where} 必须形如 <kind>:<ns>/<name> 或 <kind>:*/<name>:${p}`);
|
|
119
|
+
const ns = rest.slice(0, slash);
|
|
120
|
+
const name = rest.slice(slash + 1);
|
|
121
|
+
if (!RE_NAME.test(name)) bad('E_CONFLICT_FORM', `${where} 的 name 不合 grammar(不支持通配 / 正则):${JSON.stringify(name)}`);
|
|
122
|
+
if (ns === '*') return { form: 'any-namespace', kind, namespace: '*', name, raw: p };
|
|
123
|
+
if (!RE_NAMESPACE.test(ns)) bad('E_CONFLICT_FORM', `${where} 的 namespace 只能是合法 namespace 或整段 *:${JSON.stringify(ns)}`);
|
|
124
|
+
return { form: 'any-version', kind, namespace: ns, name, raw: p };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 某个已装/待装制品是否命中该 conflicts 项。`id` 必须是精确 ArtifactId。 */
|
|
128
|
+
export function conflictMatches(pattern, id) {
|
|
129
|
+
const pat = typeof pattern === 'string' ? parseConflictPattern(pattern) : pattern;
|
|
130
|
+
const a = typeof id === 'string' ? parseArtifactId(id) : id;
|
|
131
|
+
if (pat.kind !== a.kind || pat.name !== a.name) return false;
|
|
132
|
+
if (pat.form === 'any-namespace') return true;
|
|
133
|
+
if (pat.namespace !== a.namespace) return false;
|
|
134
|
+
return pat.form === 'any-version' ? true : pat.version === a.version;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── contract_paths(§2 + §3.1 的 D8 绕过面) ────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const RE_CP_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* contract_paths 的受限 glob。
|
|
143
|
+
*
|
|
144
|
+
* 🔴 规范只给了两个例子(`a/b/c.md` 与 `*/matrix-contract.md`),没有定义 glob 语法。
|
|
145
|
+
* 按「规范没明确允许的组合一律拒绝」定为:**`*` 只能作为一整个 segment**,
|
|
146
|
+
* 匹配恰好一层且不跨 `/`;**没有 `**`、没有部分通配(`foo*`)、没有字符类、没有正则**。
|
|
147
|
+
*
|
|
148
|
+
* 为什么要卡这么死:这是一道**安全门**的输入。部分通配与 `**` 会让「这条 pattern
|
|
149
|
+
* 到底覆盖哪些文件」变成需要推理的问题,而 D8 说得很清楚,contract_paths 本身
|
|
150
|
+
* 就是绕过面 —— 门的覆盖范围必须一眼看得出来,否则作者可以用一条看起来很宽的
|
|
151
|
+
* pattern 实际只覆盖到零个文件。
|
|
152
|
+
*/
|
|
153
|
+
export function validateContractPath(p, where = 'contract_paths[]') {
|
|
154
|
+
assertString(p, where);
|
|
155
|
+
if (p === '') bad('E_CONTRACT_PATH', `${where} 为空`);
|
|
156
|
+
if (p.startsWith('/')) bad('E_CONTRACT_PATH', `${where} 必须是相对路径:${p}`);
|
|
157
|
+
if (p.includes('\\')) bad('E_CONTRACT_PATH', `${where} 含反斜杠:${p}`);
|
|
158
|
+
if (p.includes('\0')) bad('E_CONTRACT_PATH', `${where} 含 NUL`);
|
|
159
|
+
const segs = p.split('/');
|
|
160
|
+
for (const s of segs) {
|
|
161
|
+
if (s === '') bad('E_CONTRACT_PATH', `${where} 含空 segment:${p}`);
|
|
162
|
+
if (s === '.' || s === '..') bad('E_CONTRACT_PATH', `${where} 含 ${s}:${p}`);
|
|
163
|
+
if (s === '*') continue;
|
|
164
|
+
if (s.includes('*')) bad('E_CONTRACT_PATH', `${where}:* 只能作为一整个 segment,不支持部分通配:${JSON.stringify(s)}`);
|
|
165
|
+
if (!RE_CP_SEGMENT.test(s)) bad('E_CONTRACT_PATH', `${where} 的 segment 不是 ASCII-only [A-Za-z0-9._-]:${JSON.stringify(s)}`);
|
|
166
|
+
}
|
|
167
|
+
return segs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** pattern 是否命中某条制品内路径。段数必须相等(`*` 不跨层)。 */
|
|
171
|
+
export function matchContractPath(pattern, path) {
|
|
172
|
+
const pat = validateContractPath(pattern);
|
|
173
|
+
const segs = path.split('/');
|
|
174
|
+
if (pat.length !== segs.length) return false;
|
|
175
|
+
for (let i = 0; i < pat.length; i++) {
|
|
176
|
+
if (pat[i] === '*') continue;
|
|
177
|
+
if (pat[i] !== segs[i]) return false;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 🔴 §3.1 护栏①:**实际生效的清单 = 本版声明 ∪ 上一版声明。只能加,不能减。**
|
|
184
|
+
* v1 让作者自报清单,作者清空清单即可让门形同虚设。
|
|
185
|
+
*/
|
|
186
|
+
export function effectiveContractPaths(current, previous = []) {
|
|
187
|
+
const set = new Set();
|
|
188
|
+
for (const p of current) { validateContractPath(p, 'contract_paths[](本版)'); set.add(p); }
|
|
189
|
+
for (const p of previous) { validateContractPath(p, 'contract_paths[](上一版)'); set.add(p); }
|
|
190
|
+
return [...set].sort((a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 🔴 §3.1 护栏②:`contract_paths` 本身发生变更(无论增减)→ 该 PR 自动升为 **Tier 2**。
|
|
195
|
+
* @returns {{changed:boolean, added:string[], removed:string[], tier:number}}
|
|
196
|
+
*/
|
|
197
|
+
export function contractPathsChanged(current, previous = []) {
|
|
198
|
+
const cur = new Set(current), prev = new Set(previous);
|
|
199
|
+
const added = [...cur].filter(p => !prev.has(p)).sort();
|
|
200
|
+
const removed = [...prev].filter(p => !cur.has(p)).sort();
|
|
201
|
+
const changed = added.length > 0 || removed.length > 0;
|
|
202
|
+
return { changed, added, removed, tier: changed ? 2 : 1 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 「除版本戳与日期外」的归一化。
|
|
207
|
+
*
|
|
208
|
+
* 🔴 **刻意做窄**:只替换①调用方明确给出的两个版本字符串(本版与 previous),
|
|
209
|
+
* ②`YYYY-MM-DD` 形状的日期。不做通用 semver 正则 —— 那会把契约正文里任何
|
|
210
|
+
* 形如 `1.2.3` 的内容(阈值、编号、示例)一并抹掉,等于给绕过开一扇门。
|
|
211
|
+
*
|
|
212
|
+
* ⚠️ 仍有残余绕过面:把实质变更藏进一个日期字面量里(`2026-01-01` → `1999-12-31`)
|
|
213
|
+
* 本门看不见。规范原文就是这么写的,这条如实记在交付汇报里。
|
|
214
|
+
*/
|
|
215
|
+
function normalizeContractText(buf, { currentVersion, previousVersion }) {
|
|
216
|
+
let s = buf.toString('utf8');
|
|
217
|
+
for (const v of [currentVersion, previousVersion]) {
|
|
218
|
+
if (typeof v === 'string' && v !== '') s = s.split(v).join('VERSION');
|
|
219
|
+
}
|
|
220
|
+
return s.replace(/\d{4}-\d{2}-\d{2}/g, 'DATE');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 🔴 `compatibility.previous` 必须是**同一 lineage 的直接前一版**。
|
|
225
|
+
*
|
|
226
|
+
* 不校验的话,作者可以把 `previous` 指向任意一个更早的版本,从而**跳过中间版本的
|
|
227
|
+
* contract_paths 集合**(护栏①的并集只并到那一版为止),门就被绕开了。
|
|
228
|
+
* 规范没有明写这一条 —— 它是 D8 那两条护栏能成立的前提,Codex 第一轮点出。
|
|
229
|
+
*
|
|
230
|
+
* @param {string} previous
|
|
231
|
+
* @param {string[]} publishedVersions 该 `<ns>/<name>` 下已发布的全部版本
|
|
232
|
+
*/
|
|
233
|
+
export function assertPreviousIsDirectAncestor(previous, publishedVersions, where = 'compatibility.previous') {
|
|
234
|
+
const installable = publishedVersions
|
|
235
|
+
.map(v => parseSemver(v, where))
|
|
236
|
+
.sort(compareSemver);
|
|
237
|
+
if (installable.length === 0) bad('E_COMPAT_PREVIOUS', `${where}:该 pack 还没有任何已发布版本,不该有 previous`);
|
|
238
|
+
const highest = installable[installable.length - 1];
|
|
239
|
+
if (highest.raw !== previous) {
|
|
240
|
+
bad('E_COMPAT_PREVIOUS',
|
|
241
|
+
`${where} 是 ${previous},但该 pack 已发布的最高版本是 ${highest.raw}。`
|
|
242
|
+
+ `previous 必须指向直接前一版 —— 跳版会漏掉中间版本的 contract_paths 集合(§3.1 护栏①)`);
|
|
243
|
+
}
|
|
244
|
+
return highest;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* §3 兼容性门:声明 `compatible` 时,比对本版与 `previous` 版在**生效** contract_paths
|
|
249
|
+
* 命中的全部文件;除版本戳与日期外只要有差异 → 拒绝 `compatible`。
|
|
250
|
+
*
|
|
251
|
+
* 🔴 文件的**增加与删除**同样算差异 —— 只比「两边都有的」等于放行「把契约文件删掉」,
|
|
252
|
+
* 也放行「改个名躲开 glob」。比较集合是**两版命中路径的并集**。
|
|
253
|
+
*
|
|
254
|
+
* 🔴 **同时返回两套差异**(Codex 第一轮:全文件正则归一化本身就是绕过面):
|
|
255
|
+
* · `differences` —— 归一化之后仍存在的差异,这是规范定义的门;
|
|
256
|
+
* · `strictDifferences` —— **逐字节**差异,不做任何归一化。
|
|
257
|
+
* 只在 `strictDifferences` 里出现、而不在 `differences` 里的文件,意思是
|
|
258
|
+
* 「它只在版本戳/日期上变了」。🔴 **CI 必须把这批文件打印出来交人看**,
|
|
259
|
+
* 不能因为门开了就当它们没变过 —— 把实质变更伪装成日期字面量正是这条的绕过法。
|
|
260
|
+
*
|
|
261
|
+
* @returns {{ok, effective, matched, differences, strictDifferences, normalizedOnly, tier}}
|
|
262
|
+
*/
|
|
263
|
+
export function checkPackCompat({
|
|
264
|
+
kind, contractPaths, previousContractPaths = [],
|
|
265
|
+
currentFiles, previousFiles, currentVersion, previousVersion,
|
|
266
|
+
}) {
|
|
267
|
+
if (kind !== 'compatible' && kind !== 'breaking') {
|
|
268
|
+
bad('E_COMPAT_KIND', `compatibility.kind 只能是 compatible / breaking,得到 ${JSON.stringify(kind)}`);
|
|
269
|
+
}
|
|
270
|
+
const effective = effectiveContractPaths(contractPaths, previousContractPaths);
|
|
271
|
+
const all = new Set([...currentFiles.keys(), ...previousFiles.keys()]);
|
|
272
|
+
const matched = [...all].filter(p => effective.some(pat => matchContractPath(pat, p))).sort();
|
|
273
|
+
const differences = [];
|
|
274
|
+
const strictDifferences = [];
|
|
275
|
+
for (const p of matched) {
|
|
276
|
+
const a = currentFiles.get(p), b = previousFiles.get(p);
|
|
277
|
+
if (a === undefined) { differences.push({ path: p, why: 'removed' }); strictDifferences.push({ path: p, why: 'removed' }); continue; }
|
|
278
|
+
if (b === undefined) { differences.push({ path: p, why: 'added' }); strictDifferences.push({ path: p, why: 'added' }); continue; }
|
|
279
|
+
if (!a.equals(b)) strictDifferences.push({ path: p, why: 'changed' });
|
|
280
|
+
const na = normalizeContractText(a, { currentVersion, previousVersion });
|
|
281
|
+
const nb = normalizeContractText(b, { currentVersion, previousVersion });
|
|
282
|
+
if (na !== nb) differences.push({ path: p, why: 'changed' });
|
|
283
|
+
}
|
|
284
|
+
const soft = new Set(differences.map(d => d.path));
|
|
285
|
+
const normalizedOnly = strictDifferences.map(d => d.path).filter(p => !soft.has(p));
|
|
286
|
+
// breaking 不需要过这道门(它就是在承认有差异)
|
|
287
|
+
const ok = kind === 'breaking' || differences.length === 0;
|
|
288
|
+
return {
|
|
289
|
+
ok, effective, matched, differences, strictDifferences, normalizedOnly,
|
|
290
|
+
tier: contractPathsChanged(contractPaths, previousContractPaths).tier,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── pack.json 全量校验(§2) ────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const PACK_KEYS = {
|
|
297
|
+
required: ['schema', 'kind', 'namespace', 'name', 'version', 'description', 'license',
|
|
298
|
+
'members', 'bundled', 'conflicts', 'contract_paths', 'compatibility'],
|
|
299
|
+
};
|
|
300
|
+
const MEMBER_KEYS = { required: ['id', 'tree_digest', 'role'], optional: ['order'] };
|
|
301
|
+
const COMPAT_KEYS = { required: ['previous', 'kind', 'breaking_reasons'] };
|
|
302
|
+
|
|
303
|
+
/** `members` 里只允许 role=matrix,`bundled` 里只允许 role=tool(见下面的说明)。 */
|
|
304
|
+
const ROLE_OF_LIST = Object.freeze({ members: 'matrix', bundled: 'tool' });
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* §2 全量校验。返回归一化后的 pack 模型。
|
|
308
|
+
*
|
|
309
|
+
* 🔴 **`role` 必须与它所在的列表一致**(`members`→`matrix`,`bundled`→`tool`)。
|
|
310
|
+
* 规范用一张表把 role 映到安装行为,又用两个列表区分必装/可跳过 —— 这是**两个
|
|
311
|
+
* 真值来源**。不强制一致的话,`members` 里放一条 `role: tool` 就能让一个必装成员
|
|
312
|
+
* 变成 `--no-bundled` 可跳过的,而 §5 的 degraded 判定却仍按「它在 members 里」算。
|
|
313
|
+
* 那正好是一个绕过面:yank 了它,pack 却不 degraded,装出来还缺东西。
|
|
314
|
+
*/
|
|
315
|
+
export function validatePackManifest(doc, where = 'pack.json') {
|
|
316
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) bad('E_WIRE_TYPE', `${where} 必须是对象`);
|
|
317
|
+
assertExactKeys(doc, PACK_KEYS, where);
|
|
318
|
+
if (doc.schema !== PACK_MANIFEST_SCHEMA) {
|
|
319
|
+
bad('E_SCHEMA', `${where}.schema 必须是 ${PACK_MANIFEST_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
|
|
320
|
+
}
|
|
321
|
+
if (doc.kind !== 'pack') bad('E_KIND', `${where}.kind 必须是 pack,得到 ${JSON.stringify(doc.kind)}`);
|
|
322
|
+
if (!RE_NAMESPACE.test(assertString(doc.namespace, `${where}.namespace`))) {
|
|
323
|
+
bad('E_NAMESPACE', `${where}.namespace 不合 grammar:${doc.namespace}`);
|
|
324
|
+
}
|
|
325
|
+
if (!RE_NAME.test(assertString(doc.name, `${where}.name`))) {
|
|
326
|
+
bad('E_NAME', `${where}.name 不合 grammar:${doc.name}`);
|
|
327
|
+
}
|
|
328
|
+
const selfSemver = parseSemver(doc.version, `${where}.version`);
|
|
329
|
+
assertString(doc.description, `${where}.description`);
|
|
330
|
+
assertString(doc.license, `${where}.license`);
|
|
331
|
+
|
|
332
|
+
// conflicts(§2.3)
|
|
333
|
+
assertStringArray(doc.conflicts, `${where}.conflicts`);
|
|
334
|
+
const conflicts = doc.conflicts.map((c, i) => parseConflictPattern(c, `${where}.conflicts[${i}]`));
|
|
335
|
+
|
|
336
|
+
// contract_paths(§2 + §3.1)
|
|
337
|
+
assertStringArray(doc.contract_paths, `${where}.contract_paths`);
|
|
338
|
+
doc.contract_paths.forEach((p, i) => validateContractPath(p, `${where}.contract_paths[${i}]`));
|
|
339
|
+
if (new Set(doc.contract_paths).size !== doc.contract_paths.length) {
|
|
340
|
+
bad('E_CONTRACT_PATH', `${where}.contract_paths 有重复项`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// compatibility(§3)
|
|
344
|
+
assertExactKeys(doc.compatibility, COMPAT_KEYS, `${where}.compatibility`);
|
|
345
|
+
const compat = doc.compatibility;
|
|
346
|
+
if (compat.kind !== 'compatible' && compat.kind !== 'breaking') {
|
|
347
|
+
bad('E_COMPAT_KIND', `${where}.compatibility.kind 只能是 compatible / breaking,得到 ${JSON.stringify(compat.kind)}`);
|
|
348
|
+
}
|
|
349
|
+
assertStringArray(compat.breaking_reasons, `${where}.compatibility.breaking_reasons`);
|
|
350
|
+
if (compat.previous !== null) {
|
|
351
|
+
const prev = parseSemver(compat.previous, `${where}.compatibility.previous`);
|
|
352
|
+
if (compareSemver(prev, selfSemver) >= 0) {
|
|
353
|
+
bad('E_COMPAT_PREVIOUS', `${where}.compatibility.previous (${compat.previous}) 必须严格小于本版 (${doc.version})`);
|
|
354
|
+
}
|
|
355
|
+
} else if (compat.kind === 'compatible') {
|
|
356
|
+
// 首版没有 previous 可比 → 契约零差异门无从执行。声明 compatible 是**无意义的**,
|
|
357
|
+
// 而无意义的声明会被当成「过了门」。fail-closed。
|
|
358
|
+
bad('E_COMPAT_PREVIOUS', `${where}.compatibility.previous 为 null(首版)时不得声明 compatible —— 没有可比对象,§3 的零差异门无从执行`);
|
|
359
|
+
}
|
|
360
|
+
if (compat.kind === 'compatible' && compat.breaking_reasons.length > 0) {
|
|
361
|
+
bad('E_COMPAT_KIND', `${where}.compatibility 声明 compatible 却列了 breaking_reasons`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// members / bundled(§2 成员锁定 + §2.2 role)
|
|
365
|
+
const seen = new Map();
|
|
366
|
+
const lists = {};
|
|
367
|
+
for (const listName of ['members', 'bundled']) {
|
|
368
|
+
const list = doc[listName];
|
|
369
|
+
if (!Array.isArray(list)) bad('E_WIRE_TYPE', `${where}.${listName} 必须是数组`);
|
|
370
|
+
lists[listName] = list.map((m, i) => {
|
|
371
|
+
const w = `${where}.${listName}[${i}]`;
|
|
372
|
+
if (m === null || typeof m !== 'object' || Array.isArray(m)) bad('E_WIRE_TYPE', `${w} 必须是对象`);
|
|
373
|
+
assertExactKeys(m, MEMBER_KEYS, w);
|
|
374
|
+
const a = parseArtifactId(m.id, `${w}.id`);
|
|
375
|
+
assertTreeDigest(m.tree_digest, `${w}.tree_digest`);
|
|
376
|
+
assertString(m.role, `${w}.role`);
|
|
377
|
+
if (m.role !== ROLE_OF_LIST[listName]) {
|
|
378
|
+
bad('E_PACK_ROLE', `${w}.role 是 ${JSON.stringify(m.role)},但它在 ${listName} 里 —— ${listName} 只允许 role=${ROLE_OF_LIST[listName]}(§2.2)`);
|
|
379
|
+
}
|
|
380
|
+
if (Object.hasOwn(m, 'order')) assertUint(m.order, `${w}.order`);
|
|
381
|
+
// 🔴 同一个成员不得在两个列表里出现,也不得重复:否则「必装还是可跳过」有两个答案,
|
|
382
|
+
// §5 的 degraded 判定与 --no-bundled 会给出互相矛盾的结果。
|
|
383
|
+
if (seen.has(m.id)) bad('E_PACK_MEMBER_DUP', `${w}.id 重复出现:${m.id}(先前在 ${seen.get(m.id)})`);
|
|
384
|
+
seen.set(m.id, listName);
|
|
385
|
+
// pack 不得把自己列为成员(自引用闭包不收敛)
|
|
386
|
+
if (a.kind === 'pack' && a.namespace === doc.namespace && a.name === doc.name) {
|
|
387
|
+
bad('E_PACK_SELF_MEMBER', `${w}.id 引用了 pack 自己:${m.id}`);
|
|
388
|
+
}
|
|
389
|
+
return { ...m, parsed: a, list: listName };
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (lists.members.length === 0) {
|
|
393
|
+
bad('E_PACK_NO_MEMBERS', `${where}.members 为空 —— 缺一个成员的矩阵不是矩阵(§2.2),空矩阵更不是`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
...doc,
|
|
398
|
+
members: lists.members,
|
|
399
|
+
bundled: lists.bundled,
|
|
400
|
+
conflicts,
|
|
401
|
+
_semver: selfSemver,
|
|
402
|
+
id: formatArtifactId({ kind: 'pack', namespace: doc.namespace, name: doc.name, version: doc.version }),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── §2.1 clients / capabilities 的推导 ─────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 🔴 pack **不声明** `clients` / `capabilities`;registry 对 pack 记录的这两个字段
|
|
410
|
+
* 由 promotion **计算**并写入快照。
|
|
411
|
+
*
|
|
412
|
+
* `clients` = 全体 `members` 的 clients **交集**(`bundled` 不参与 —— 它可被
|
|
413
|
+
* `--no-bundled` 跳过,让它参与交集会让一个可跳过的成员否掉整个 pack 的可装性)。
|
|
414
|
+
* 交集为空 → 该 pack 不可安装,promotion 阶段直接拒绝。
|
|
415
|
+
*/
|
|
416
|
+
export function derivePackClients(memberRecords) {
|
|
417
|
+
if (!Array.isArray(memberRecords) || memberRecords.length === 0) {
|
|
418
|
+
bad('E_PACK_NO_MEMBERS', 'derivePackClients:members 不能为空');
|
|
419
|
+
}
|
|
420
|
+
let acc = null;
|
|
421
|
+
for (const r of memberRecords) {
|
|
422
|
+
const s = new Set(r.clients);
|
|
423
|
+
acc = acc === null ? s : new Set([...acc].filter(c => s.has(c)));
|
|
424
|
+
}
|
|
425
|
+
return [...acc].sort();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** `capabilities` = 全体 `members` + `bundled` 的**并集**;pack 自身审查 Tier 取最高。 */
|
|
429
|
+
export function derivePackCapabilities(allRecords) {
|
|
430
|
+
const s = new Set();
|
|
431
|
+
for (const r of allRecords) for (const c of r.capabilities) s.add(c);
|
|
432
|
+
return [...s].sort();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── §5 yank 闭包 ───────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
const INSTALLABLE_MEMBER_STATUS = new Set(['published', 'deprecated']);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 🔴 §5:**yank 一个 skill 不会自动 yank 引用它的 pack** —— pack 不可变,改不了
|
|
441
|
+
* 它锁定的成员。受影响的 pack 在**下一张快照**里被重算为 `degraded`。
|
|
442
|
+
*
|
|
443
|
+
* | 情形 | status | 能否新装 |
|
|
444
|
+
* |---|---|---|
|
|
445
|
+
* | 全部成员 published / deprecated | published | 能 |
|
|
446
|
+
* | 任一 members(必装)被 yank | **degraded** | 否,报错点名成员与 advisory |
|
|
447
|
+
* | 只有 bundled 成员被 yank | published | 能,但那项被跳过并告警 |
|
|
448
|
+
* | pack 自身被 yank | yanked | 否 |
|
|
449
|
+
*
|
|
450
|
+
* 🔴 **`degraded` 是派生状态,必须由 promotion 每次重算并写进快照** —— 快照是签名
|
|
451
|
+
* 对象,状态必须在签名覆盖范围内。**不得在运行时算出来当真值用。** 本函数就是
|
|
452
|
+
* promotion 侧的那一次计算;安装侧只读快照里的 `status`。
|
|
453
|
+
*
|
|
454
|
+
* 🔴 **成员缺失(不在快照里)按 degraded 记**,且比被 yank 更糟:yank 至少留着文件
|
|
455
|
+
* 可取证,缺失连取证对象都没有。规范只列了 yank,但两者对「能不能装」的答案相同,
|
|
456
|
+
* 而 fail-open 会让一个引用了不存在成员的 pack 显示成 published。
|
|
457
|
+
*
|
|
458
|
+
* 🔴 **成员本身是 pack 且为 degraded → 传递上来**(§4 解析顺序第 2 步:
|
|
459
|
+
* 「所属 pack 为 degraded → 整个安装终止」)。不传递的话嵌套 pack 就是一个绕过面。
|
|
460
|
+
*
|
|
461
|
+
* @param {object} a
|
|
462
|
+
* @param {'published'|'deprecated'|'yanked'} a.selfStatus pack 自身在快照里的 status
|
|
463
|
+
* @param {object} a.manifest validatePackManifest() 的结果
|
|
464
|
+
* @param {(id:string)=>({status:string, advisory?:string}|undefined)} a.statusOf
|
|
465
|
+
* @returns {{status:string, degradedBy:Array, skippedBundled:Array}}
|
|
466
|
+
*/
|
|
467
|
+
export function computePackStatus({ selfStatus, manifest, statusOf }) {
|
|
468
|
+
if (selfStatus === 'yanked') return { status: 'yanked', degradedBy: [], skippedBundled: [] };
|
|
469
|
+
if (selfStatus === 'degraded') {
|
|
470
|
+
// 传进来的就是派生值,不能拿它当输入再算一遍(会把上一次的结论当事实)
|
|
471
|
+
bad('E_PACK_STATUS_INPUT', 'computePackStatus 的 selfStatus 不接受 degraded —— 它是本函数的输出,不是输入');
|
|
472
|
+
}
|
|
473
|
+
const degradedBy = [];
|
|
474
|
+
const skippedBundled = [];
|
|
475
|
+
const look = (m) => {
|
|
476
|
+
const r = statusOf(m.id);
|
|
477
|
+
if (r === undefined) return { reason: 'missing', status: null, advisory: undefined };
|
|
478
|
+
if (INSTALLABLE_MEMBER_STATUS.has(r.status)) return null;
|
|
479
|
+
return { reason: r.status, status: r.status, advisory: r.advisory };
|
|
480
|
+
};
|
|
481
|
+
for (const m of manifest.members) {
|
|
482
|
+
const problem = look(m);
|
|
483
|
+
if (problem) degradedBy.push({ id: m.id, role: m.role, ...problem });
|
|
484
|
+
}
|
|
485
|
+
for (const m of manifest.bundled) {
|
|
486
|
+
const problem = look(m);
|
|
487
|
+
if (problem) skippedBundled.push({ id: m.id, role: m.role, ...problem });
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
status: degradedBy.length > 0 ? 'degraded' : (selfStatus === 'deprecated' ? 'deprecated' : 'published'),
|
|
491
|
+
degradedBy,
|
|
492
|
+
skippedBundled,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 🔴 **嵌套 pack 的传递闭包**(Codex 第一轮 #2)。
|
|
498
|
+
*
|
|
499
|
+
* `computePackStatus()` 只看直接成员的 `status`。当成员本身是 pack 时,那个
|
|
500
|
+
* status 也是派生的 —— 如果 promotion 没有按**拓扑序**重算,外层看到的就是上一张
|
|
501
|
+
* 快照的陈旧值。本函数自己把闭包算完,并且:
|
|
502
|
+
*
|
|
503
|
+
* 🔴 **检测环**。`A → B → A` 在朴素递归下要么不终止、要么按访问顺序得到不确定的结果。
|
|
504
|
+
* 环本身就是非法的(pack 不可变,环意味着两个 pack 互相锁定对方的摘要,
|
|
505
|
+
* 第一个都发布不出来),所以判为**错误而不是 degraded**。
|
|
506
|
+
*
|
|
507
|
+
* 🔴 **按边的 role 算,不是按节点状态算**:同一个 nested pack 可以在 X 处是必装、
|
|
508
|
+
* 在 Y 处是 bundled。它 degraded 只应该拖垮把它列为必装的那一个。
|
|
509
|
+
*
|
|
510
|
+
* @param {object} a
|
|
511
|
+
* @param {string} a.rootId
|
|
512
|
+
* @param {(id:string)=>({status:string, advisory?:string, manifest?:object}|undefined)} a.lookup
|
|
513
|
+
* 对 pack 成员必须返回 `manifest`(validatePackManifest 的结果),否则无法下探。
|
|
514
|
+
* @returns {Map<string, {status, degradedBy, skippedBundled}>} 闭包内每个 pack 的结论
|
|
515
|
+
*/
|
|
516
|
+
export function computePackStatusClosure({ rootId, lookup }) {
|
|
517
|
+
const out = new Map();
|
|
518
|
+
const state = new Map(); // id -> 'visiting' | 'done'
|
|
519
|
+
|
|
520
|
+
const visit = (id, stack) => {
|
|
521
|
+
if (state.get(id) === 'done') return out.get(id);
|
|
522
|
+
if (state.get(id) === 'visiting') {
|
|
523
|
+
bad('E_PACK_CYCLE', `pack 成员图有环:${[...stack, id].join(' → ')}`);
|
|
524
|
+
}
|
|
525
|
+
const rec = lookup(id);
|
|
526
|
+
if (rec === undefined) return undefined; // 缺失由调用方按 missing 处理
|
|
527
|
+
if (rec.manifest === undefined) return { status: rec.status, degradedBy: [], skippedBundled: [] };
|
|
528
|
+
state.set(id, 'visiting');
|
|
529
|
+
const next = [...stack, id];
|
|
530
|
+
|
|
531
|
+
// 🔴 **先无条件走一遍成员图,再谈状态。**
|
|
532
|
+
// 早先的写法把 `selfStatus === 'yanked'` 的短路放在遍历之前,于是
|
|
533
|
+
// 「一个 yanked 的 pack 参与的环」根本走不到检测点(Codex 第二轮 #2 的反例:
|
|
534
|
+
// A(yanked) → B(published) → A,从 A 出发不报环,只返回 A=yanked)。
|
|
535
|
+
// 环是**图的性质**,与节点当下是什么状态无关;用状态去短路图的遍历,
|
|
536
|
+
// 等于让攻击者靠 yank 一个节点把环藏起来。
|
|
537
|
+
for (const m of [...rec.manifest.members, ...rec.manifest.bundled]) {
|
|
538
|
+
if (parseArtifactId(m.id, 'member id').kind === 'pack') visit(m.id, next);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let res;
|
|
542
|
+
if (rec.status === 'yanked') {
|
|
543
|
+
res = { status: 'yanked', degradedBy: [], skippedBundled: [] };
|
|
544
|
+
} else {
|
|
545
|
+
const statusOf = (mid) => {
|
|
546
|
+
const r = lookup(mid);
|
|
547
|
+
if (r === undefined) return undefined;
|
|
548
|
+
const parsed = parseArtifactId(mid, 'member id');
|
|
549
|
+
if (parsed.kind !== 'pack') return { status: r.status, advisory: r.advisory };
|
|
550
|
+
const sub = out.get(mid);
|
|
551
|
+
if (sub === undefined) return { status: r.status, advisory: r.advisory };
|
|
552
|
+
return { status: sub.status, advisory: r.advisory };
|
|
553
|
+
};
|
|
554
|
+
res = computePackStatus({
|
|
555
|
+
selfStatus: rec.status === 'degraded' ? 'published' : rec.status,
|
|
556
|
+
manifest: rec.manifest,
|
|
557
|
+
statusOf,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
state.set(id, 'done');
|
|
561
|
+
out.set(id, res);
|
|
562
|
+
return res;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// 🔴 root 查不到不是「空结果」,是拒绝。返回空 Map 会让调用方以为
|
|
566
|
+
// 「这个 pack 没问题,只是没有子图」(Codex 第二轮 #2)。
|
|
567
|
+
if (lookup(rootId) === undefined) bad('E_PACK_MISSING', `${rootId} 不在快照里`);
|
|
568
|
+
visit(rootId, []);
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── §5 latest 排除 degraded / 02-registry.md §2.3 ──────────────────────────
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 🔴 `latest` 排除 `degraded`(与 yanked、prerelease 一样)。
|
|
576
|
+
* 否则 `install pack:x`(不带版本)会选中最高版、而它恰好 degraded,**安装必然失败**。
|
|
577
|
+
*
|
|
578
|
+
* 全部版本都 degraded → 返回 `null`,并由 `explainNoInstallableVersion()` 列出
|
|
579
|
+
* 每个版本被哪个成员拖累(§5 末段明确要求「列出各版本被哪个成员拖累」)。
|
|
580
|
+
*
|
|
581
|
+
* @param {Array<{version:string,status:string}>} candidates
|
|
582
|
+
* @param {{pre?:boolean}} [opts] `--pre` 时才考虑预发布
|
|
583
|
+
*/
|
|
584
|
+
export function selectInstallableVersion(candidates, { pre = false } = {}) {
|
|
585
|
+
let best = null;
|
|
586
|
+
for (const c of candidates) {
|
|
587
|
+
if (c.status === 'yanked' || c.status === 'degraded') continue;
|
|
588
|
+
const sv = parseSemver(c.version, 'version');
|
|
589
|
+
if (sv.prerelease !== null && !pre) continue;
|
|
590
|
+
if (best === null || compareSemver(sv, best.semver) > 0) best = { ...c, semver: sv };
|
|
591
|
+
}
|
|
592
|
+
return best;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function explainNoInstallableVersion(candidates, degradedByVersion = new Map()) {
|
|
596
|
+
return candidates
|
|
597
|
+
.map(c => ({
|
|
598
|
+
version: c.version,
|
|
599
|
+
status: c.status,
|
|
600
|
+
degraded_by: (degradedByVersion.get(c.version) ?? []).map(d => d.id),
|
|
601
|
+
}))
|
|
602
|
+
.sort((a, b) => compareSemver(parseSemver(a.version, 'v'), parseSemver(b.version, 'v')));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── §4 安装解析 ────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* §4 的解析顺序 1–5(本函数覆盖 2–4;第 1 步验签验摘要由 artifact.mjs 做,
|
|
609
|
+
* 第 5 步的暂存/交换由 install.mjs 做)。
|
|
610
|
+
*
|
|
611
|
+
* 🔴 **任何一个成员不存在 / 摘要不符 / 是 degraded 的 pack → 整个安装终止。**
|
|
612
|
+
* 不做「跳过坏的装剩下的」—— 缺一个成员的矩阵不是矩阵(§2.2)。
|
|
613
|
+
*
|
|
614
|
+
* 🔴 **`--allow-yanked` 不放行 `degraded`**(§5):所以本函数没有任何参数
|
|
615
|
+
* 能让 degraded 通过。这是刻意的 —— 有那个参数就一定会有人传。
|
|
616
|
+
*
|
|
617
|
+
* @param {object} a
|
|
618
|
+
* @param {object} a.manifest validatePackManifest() 的结果
|
|
619
|
+
* @param {object} a.packRecord 快照里 pack 自己的 record(含 status / clients)
|
|
620
|
+
* @param {(id:string)=>object|undefined} a.lookup 快照查询:id → record
|
|
621
|
+
* @param {{noBundled?:boolean, allowYanked?:boolean}} [a.intent]
|
|
622
|
+
* @param {string} [a.client] 目标 target 的 client(§4 第 3 步)
|
|
623
|
+
* @returns {{install:Array, skipped:Array, conflicts:Array, clients:string[]}}
|
|
624
|
+
*/
|
|
625
|
+
export function resolvePackInstall({ manifest, packRecord, lookup, intent = {}, client = null }) {
|
|
626
|
+
const { noBundled = false, allowYanked = false } = intent;
|
|
627
|
+
|
|
628
|
+
// 第 1 步的收尾:pack 自身的状态门
|
|
629
|
+
if (packRecord.status === 'degraded') {
|
|
630
|
+
bad('E_PACK_DEGRADED',
|
|
631
|
+
`${manifest.id} 在快照里是 degraded,不可新装。`
|
|
632
|
+
+ `--allow-yanked 不放行 degraded(03-packs.md §5 / 04-install.md §8.1.1)`);
|
|
633
|
+
}
|
|
634
|
+
if (packRecord.status === 'yanked' && !allowYanked) {
|
|
635
|
+
bad('E_PACK_YANKED', `${manifest.id} 已被 yank,不可新装`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 🔴 pack.json 里冗余记的 tree_digest 必须与快照里那份一致;不一致 → **终止**
|
|
639
|
+
// 并报告为完整性事件(§2)。
|
|
640
|
+
const install = [];
|
|
641
|
+
const skipped = [];
|
|
642
|
+
const wanted = [
|
|
643
|
+
...manifest.members.map(m => ({ m, required: true })),
|
|
644
|
+
...manifest.bundled.map(m => ({ m, required: false })),
|
|
645
|
+
];
|
|
646
|
+
for (const { m, required } of wanted) {
|
|
647
|
+
if (!required && noBundled) { skipped.push({ id: m.id, why: 'no-bundled' }); continue; }
|
|
648
|
+
const rec = lookup(m.id);
|
|
649
|
+
if (rec === undefined) {
|
|
650
|
+
bad('E_PACK_MEMBER_MISSING', `成员 ${m.id} 不在快照里,整个安装终止(§4 第 2 步)`);
|
|
651
|
+
}
|
|
652
|
+
if (rec.tree_digest !== m.tree_digest) {
|
|
653
|
+
bad('E_PACK_MEMBER_DIGEST',
|
|
654
|
+
`完整性事件:成员 ${m.id} 在 pack.json 里锁的是 ${m.tree_digest},`
|
|
655
|
+
+ `快照里是 ${rec.tree_digest}(§2:两处不一致 → 终止并报告)`);
|
|
656
|
+
}
|
|
657
|
+
if (rec.status === 'degraded') {
|
|
658
|
+
// 🔴 **规范缺口,按「与 §5 的 bundled 行一致」决议**(Codex 两轮都点到)。
|
|
659
|
+
//
|
|
660
|
+
// §4 第 2 步说「所属 pack 为 degraded → 整个安装终止」,没区分必装与 bundled;
|
|
661
|
+
// §5 的表只为 **yanked** 的 bundled 开了「跳过并告警」的口子,没提 degraded。
|
|
662
|
+
//
|
|
663
|
+
// 我第一版选了 fail-closed(bundled degraded 也终止)。Codex 第二轮指出那会
|
|
664
|
+
// 造成一处**语义不一致**:`computePackStatus()` 按 §5 把 bundled 的问题只记进
|
|
665
|
+
// `skippedBundled`、pack 自身仍算 `published`,于是**快照写着 published、
|
|
666
|
+
// 普通安装却必然失败**。用户看到的是「它说能装,装不上」——
|
|
667
|
+
// 两个真值来源,正是本仓库反复在消灭的形状。
|
|
668
|
+
//
|
|
669
|
+
// 统一到哪一边?统一到 §5:**bundled 成员按定义就是可跳过的**,
|
|
670
|
+
// 它 degraded 与它被 yank 对「这个 pack 还能不能装」的答案应该相同。
|
|
671
|
+
// 拒绝安装并不更安全 —— 用户加个 `--no-bundled` 就过了,只是多绕一圈;
|
|
672
|
+
// 而不一致是会误导人的。跳过时**必须告警**(调用方读 `skipped` 的 why)。
|
|
673
|
+
//
|
|
674
|
+
// ⚠️ 这条是取舍不是定论。要反过来选 fail-closed 也行,但**两处必须同时改**:
|
|
675
|
+
// 这里 + `computePackStatus()` 对 bundled 的处理。已写进交付汇报待拍板。
|
|
676
|
+
if (required) {
|
|
677
|
+
bad('E_PACK_MEMBER_DEGRADED', `必装成员 ${m.id} 是 degraded 的 pack,整个安装终止(§4 第 2 步)`);
|
|
678
|
+
}
|
|
679
|
+
skipped.push({ id: m.id, why: 'bundled-degraded' });
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (rec.status === 'yanked') {
|
|
683
|
+
if (required) {
|
|
684
|
+
bad('E_PACK_MEMBER_YANKED',
|
|
685
|
+
`必装成员 ${m.id} 已被 yank,本 pack 应当是 degraded 状态;拒绝安装`);
|
|
686
|
+
}
|
|
687
|
+
// bundled 被 yank:§5 —— 跳过并告警,pack 仍是 published
|
|
688
|
+
skipped.push({ id: m.id, why: 'bundled-yanked' });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
install.push({ id: m.id, role: m.role, required, record: rec, tree_digest: m.tree_digest });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 第 3 步:客户端兼容性。pack 的 clients 是成员交集,由 promotion 写进快照。
|
|
695
|
+
if (client !== null && !packRecord.clients.includes(client)) {
|
|
696
|
+
bad('E_CLIENT_UNSUPPORTED',
|
|
697
|
+
`${manifest.id} 的 clients(成员交集)不含 ${client}(§4 第 3 步)`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// 第 4 步:冲突清单(由调用方与 target 现状比对后决定是否需要 --replace <name>)
|
|
701
|
+
const conflicts = manifest.conflicts.map(c => c.raw);
|
|
702
|
+
|
|
703
|
+
return { install, skipped, conflicts, clients: packRecord.clients };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── §4.1 refcount ──────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
const byteAsc = (a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
709
|
+
|
|
710
|
+
// 🔴🔴 **这一节全是纯函数,它们不定义「什么时候生效」——上层必须把它们用在
|
|
711
|
+
// `ledger_image.post` 里,且只在 04-install.md §5.2 的第 9 步。**
|
|
712
|
+
//
|
|
713
|
+
// Codex 第一轮 #3 的结论,逐条抄在这里免得接线时走偏:
|
|
714
|
+
// · 不得在 `commitPoint`、下载、stage、或「某个成员装成功了」的时刻改 roots/refcount;
|
|
715
|
+
// 部分成员失败会留下 pack root 与悬挂的 `requested_by`。
|
|
716
|
+
// · 顺序是:① 全部物理交换完成 → ② 目标树摘要复验通过 → ③ assertions 复验通过
|
|
717
|
+
// → ④ 才把 roots / requested_by 写进 ledger post,一次原子写。
|
|
718
|
+
// · 升级时「删旧 root + 加新 root + 成员差集的引用变化」必须在**同一个 post image** 里。
|
|
719
|
+
// · `requested_by` 变空之后的**物理删除属于同一个事务**,不是本模块 API 的即时副作用。
|
|
720
|
+
// 所以 `removeRequestedBy()` 只回一个 `removeDirectory` 标志,它自己不删任何东西。
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* 每个成员在账本里的 `requested_by` 追加一个 **root key 字符串**。
|
|
724
|
+
* 🔴 去重 + 字节序严格升序 —— `ledger.validateEntry()` 就是这么校的,写入端必须
|
|
725
|
+
* 先满足读回端(R-11 的判据:写入端接受的每一个输入,读取端都必须接受)。
|
|
726
|
+
* 🔴 root key 过 grammar:R-11 记着「`../escape` 能当 root key 被接受」。
|
|
727
|
+
*/
|
|
728
|
+
export function addRequestedBy(list, rootKey) {
|
|
729
|
+
parseRootKey(rootKey);
|
|
730
|
+
const s = new Set(list);
|
|
731
|
+
s.add(rootKey);
|
|
732
|
+
return [...s].sort(byteAsc);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* `remove <skill>` 只在移除请求方后 `requested_by` 为空时才真正删目录(§4.1)。
|
|
737
|
+
* @returns {{requested_by:string[], removeDirectory:boolean}}
|
|
738
|
+
*/
|
|
739
|
+
export function removeRequestedBy(list, rootKey) {
|
|
740
|
+
parseRootKey(rootKey);
|
|
741
|
+
const next = list.filter(k => k !== rootKey).sort(byteAsc);
|
|
742
|
+
return { requested_by: next, removeDirectory: next.length === 0 };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* 校验一份账本的 root ↔ requested_by 图是闭合的。
|
|
747
|
+
* 🔴 R-11 的第二条:`validateEntry()` 不确认 `requested_by` 指向同一 ledger 的 root,
|
|
748
|
+
* 悬挂键要到投影 lockfile 时才被拒。本函数是那道缺失的门(上层接线)。
|
|
749
|
+
*/
|
|
750
|
+
export function assertRefGraphClosed(ledger, where = 'ledger') {
|
|
751
|
+
const roots = new Set(Object.keys(ledger.roots ?? {}));
|
|
752
|
+
for (const k of roots) parseRootKey(k, `${where}.roots[${k}]`);
|
|
753
|
+
for (const [name, e] of Object.entries(ledger.entries ?? {})) {
|
|
754
|
+
for (const k of e.requested_by ?? []) {
|
|
755
|
+
if (!roots.has(k)) {
|
|
756
|
+
bad('E_REF_DANGLING', `${where}.entries[${name}].requested_by 里的 ${JSON.stringify(k)} 不在 roots 里(悬挂引用)`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── §4.2 升级 ──────────────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* `update pack:<name>`:解析新 pack → 成员差集。
|
|
767
|
+
* 版本变化则同事务替换;新增则安装;移除则减引用,空了才删。
|
|
768
|
+
*
|
|
769
|
+
* 🔴 **绝不因「成员集合变了」就整体拒绝更新**(那是先例 `update.py` 为矩阵一致性
|
|
770
|
+
* 做的规则,§4.2 明令不要)。
|
|
771
|
+
*
|
|
772
|
+
* 比对键是 `<kind>:<ns>/<name>`(不含版本)—— 同一个 skill 换了版本是 `changed`,
|
|
773
|
+
* 不是「删一个加一个」。
|
|
774
|
+
*/
|
|
775
|
+
export function diffPackMembers(oldManifest, newManifest) {
|
|
776
|
+
const key = (m) => `${m.parsed.kind}:${m.parsed.namespace}/${m.parsed.name}`;
|
|
777
|
+
const idx = (man) => new Map([...man.members, ...man.bundled].map(m => [key(m), m]));
|
|
778
|
+
const a = idx(oldManifest), b = idx(newManifest);
|
|
779
|
+
const added = [], removed = [], changed = [], unchanged = [];
|
|
780
|
+
for (const [k, m] of b) {
|
|
781
|
+
const old = a.get(k);
|
|
782
|
+
if (old === undefined) { added.push({ key: k, to: m }); continue; }
|
|
783
|
+
if (old.id !== m.id || old.tree_digest !== m.tree_digest || old.list !== m.list) {
|
|
784
|
+
changed.push({ key: k, from: old, to: m });
|
|
785
|
+
} else unchanged.push({ key: k, member: m });
|
|
786
|
+
}
|
|
787
|
+
for (const [k, m] of a) if (!b.has(k)) removed.push({ key: k, from: m });
|
|
788
|
+
const byKey = (x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0);
|
|
789
|
+
return { added: added.sort(byKey), removed: removed.sort(byKey), changed: changed.sort(byKey), unchanged: unchanged.sort(byKey) };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export { WireError };
|