@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,265 @@
1
+ // 退出码 —— 规格见 09-cli.md §6。
2
+ //
3
+ // 🔴 本模块**不 import 任何内核模块**。
4
+ // 理由:它要给 `recover.mjs` / `trust.mjs` / `lock.mjs` / `journal.mjs` 抛出来的错分类,
5
+ // 真去 import 它们会立刻绕成环(命令面 → 内核 → 退出码 → 内核)。
6
+ // 因此分类**只按错误对象自身可观测的属性**:`exitCode` → `name` → 鸭子类型字段。
7
+ //
8
+ // 🔴 分类是**白名单式**的:认不出来的错**不给 0**(见下面 `UNCLASSIFIED` 的说明),
9
+ // 绝不静默变成成功。
10
+
11
+ /** 09-cli.md §6 的那张表,一个不多一个不少。 */
12
+ export const EXIT = Object.freeze({
13
+ /** 全部成功(`skipped: 目录不存在` / `skipped: unsupported` 算成功) */
14
+ OK: 0,
15
+ /** 用法错误 / 解析失败 / 候选歧义 */
16
+ USAGE: 1,
17
+ /** 完整性失败:验签失败、摘要不符、算法不认识、资产 sha256 不符、签名身份不对 */
18
+ INTEGRITY: 2,
19
+ /** 冲突未解决 */
20
+ CONFLICT: 3,
21
+ /** 部分 target 失败 */
22
+ PARTIAL: 4,
23
+ /** 残留事务需 recover;或锁被占用 */
24
+ NEEDS_RECOVER: 5,
25
+ /** 网络 / 缓存未命中 */
26
+ NETWORK: 6,
27
+ /** 需要认证或权限不足 */
28
+ AUTH: 7,
29
+ /** 陈旧:timestamp 过期且未给 --allow-stale */
30
+ STALE: 8,
31
+ /** 平台 / 文件系统不受支持;或 .geoly 是挂载点、检出嵌套 target */
32
+ UNSUPPORTED: 9,
33
+ /** target 不可写(无法创建 <target>/.geoly/) */
34
+ NOT_WRITABLE: 10,
35
+ /** CLI 版本低于 timestamp 的 min_cli_version */
36
+ MIN_CLI: 11,
37
+ });
38
+
39
+ /**
40
+ * 🔴 §6 **没有**为「CLI 自身出了 bug」开一格,而我们也不发明第 12 个码。
41
+ *
42
+ * 认不出来的错必须有一个去处,且那个去处**绝不能是 0**。这里落到 2,
43
+ * 并且**只在退出码这一层**与「制品有问题」同格 —— 两者的区分放在别处:
44
+ * · 人类输出以「内部错误(CLI 自身的 bug,不是制品有问题):」开头;
45
+ * · `--json` 的 `error.unclassified` 为 `true`。
46
+ *
47
+ * ⚠️ **诚实边界**:只看退出码是分不出这两者的。要分就得读 JSON 或文案。
48
+ */
49
+ export const UNCLASSIFIED = EXIT.INTEGRITY;
50
+
51
+ // ── 命令面自己的错误类型 ────────────────────────────────────────────────────
52
+ //
53
+ // 🔴 每一个都带 `exitCode`,这样 `classify()` 的第一档就能定死,
54
+ // 不必靠 `instanceof`(跨 ESM 实例的 instanceof 不可靠)也不必靠文案匹配。
55
+
56
+ class CliError extends Error {
57
+ constructor(message, exitCode, extra = {}) {
58
+ super(message);
59
+ this.name = new.target.name;
60
+ this.exitCode = exitCode;
61
+ Object.assign(this, extra);
62
+ }
63
+ }
64
+
65
+ /** 用法错误:未知 flag、缺参数、被明令删除的开关(--no-verify 等)。 */
66
+ export class UsageError extends CliError {
67
+ constructor(message, extra) { super(message, EXIT.USAGE, extra); }
68
+ }
69
+
70
+ /** §5 解析规则:多 namespace 同名、skill 与 pack 同名 —— **报错列候选,不猜**。 */
71
+ export class AmbiguousError extends CliError {
72
+ constructor(message, candidates) {
73
+ super(message, EXIT.USAGE, { candidates: Object.freeze([...candidates]) });
74
+ }
75
+ }
76
+
77
+ /** 冲突未解决:未认领同名目录未给 --replace、§8.2 遮蔽未给 --shadow-global。 */
78
+ export class ConflictError extends CliError {
79
+ constructor(message, extra) { super(message, EXIT.CONFLICT, extra); }
80
+ }
81
+
82
+ /**
83
+ * 部分 target 失败。🔴 只在「至少一个成功、至少一个失败」时用 ——
84
+ * 全失败不是「部分失败」,那应当照最严重的那条单项错误报。
85
+ */
86
+ export class PartialFailure extends CliError {
87
+ constructor(message, results) { super(message, EXIT.PARTIAL, { results }); }
88
+ }
89
+
90
+ /** 网络 / 缓存未命中(含 `--offline` 下缓存没命中)。 */
91
+ export class NetworkError extends CliError {
92
+ constructor(message, extra) { super(message, EXIT.NETWORK, extra); }
93
+ }
94
+
95
+ /** 需要认证或权限不足。 */
96
+ export class AuthError extends CliError {
97
+ constructor(message, extra) { super(message, EXIT.AUTH, extra); }
98
+ }
99
+
100
+ /** 平台 / 文件系统不受支持(win32 非 WSL、拒绝的 fstype、挂载点、嵌套 target)。 */
101
+ export class UnsupportedError extends CliError {
102
+ constructor(message, extra) { super(message, EXIT.UNSUPPORTED, extra); }
103
+ }
104
+
105
+ // ── 预检违规码 → 退出码 ─────────────────────────────────────────────────────
106
+ //
107
+ // 键取自 `target.mjs` 的 `V`。🔴 这里**写死字符串而不是 import V**:
108
+ // 本模块不 import 内核(见文件头)。两边漂了会被 `test/cli-exit-codes.test.mjs`
109
+ // 的「V 的每一个取值都在这张表里」那条断言抓住。
110
+
111
+ const VIOLATION_EXIT = Object.freeze({
112
+ 'fs.unsupported-fstype': EXIT.UNSUPPORTED, // §2.2
113
+ 'fs.cross-device': EXIT.UNSUPPORTED, // §2.2
114
+ 'geoly.is-mount-point': EXIT.UNSUPPORTED, // §3.4 → §6 第 9 条点名
115
+ 'geoly.mount-point-under': EXIT.UNSUPPORTED, // §3.4
116
+ 'target.nested': EXIT.UNSUPPORTED, // §3.5 → §6 第 9 条点名
117
+ 'target.symlink-in-chain': EXIT.UNSUPPORTED, // §3.4
118
+ 'target.not-plain-dir': EXIT.UNSUPPORTED, // §3.4
119
+ 'geoly.symlink-state-path': EXIT.UNSUPPORTED, // §3.4
120
+ 'geoly.not-plain': EXIT.UNSUPPORTED, // §3.4
121
+ // 🔴 「扫不完就不能宣称没有」——它是 fail-closed 的拒绝,不是「不支持」,
122
+ // 但 §6 里没有第二个能装它的格子。归 9 并在文案里说清是扫描超预算。
123
+ 'target.nested-scan-incomplete': EXIT.UNSUPPORTED,
124
+ 'geoly.state-scan-incomplete': EXIT.UNSUPPORTED,
125
+ 'target.not-writable': EXIT.NOT_WRITABLE, // §3.6 → §6 第 10 条点名
126
+ // 🔴 可信 base 缺失是**我们自己**没把 adapter 的 base 传进去 —— 用户改不了它。
127
+ // 报 9(「不支持」)会把用户送去查文件系统。归 1(用法/内部)并点名。
128
+ 'target.base-missing': EXIT.USAGE,
129
+ 'target.outside-base': EXIT.USAGE,
130
+ });
131
+
132
+ /**
133
+ * 🔴 一次预检会报**全部**违规项,可能同时命中不同档。取哪一个?
134
+ *
135
+ * 取**最根本的死路**,不是「第一条」也不是「码最大的那条」:
136
+ * `.geoly` 在 NFS 上(9)与「目录不可写」(10)同时命中时,报 10 会让用户去 `chmod`,
137
+ * 而 chmod 完照样装不上 —— fstype 是他改不掉的那一条。
138
+ *
139
+ * 顺序即优先级;表里没有的码排在最后。
140
+ */
141
+ const VIOLATION_PRIORITY = Object.freeze([
142
+ 'fs.unsupported-fstype',
143
+ 'fs.cross-device',
144
+ 'geoly.is-mount-point',
145
+ 'geoly.mount-point-under',
146
+ 'target.nested',
147
+ 'target.symlink-in-chain',
148
+ 'geoly.symlink-state-path',
149
+ 'target.not-plain-dir',
150
+ 'geoly.not-plain',
151
+ 'target.nested-scan-incomplete',
152
+ 'geoly.state-scan-incomplete',
153
+ 'target.base-missing',
154
+ 'target.outside-base',
155
+ 'target.not-writable',
156
+ ]);
157
+
158
+ /** 一组预检违规 → 一个退出码。空数组返回 `null`(调用方自己决定)。 */
159
+ export function exitForViolations(violations) {
160
+ if (!Array.isArray(violations) || violations.length === 0) return null;
161
+ const codes = new Set(violations.map((v) => v?.code));
162
+ for (const c of VIOLATION_PRIORITY) if (codes.has(c)) return VIOLATION_EXIT[c];
163
+ // 认不出来的违规码:fail-closed 到 9,并让 classify 的调用方看得见 unclassified
164
+ return EXIT.UNSUPPORTED;
165
+ }
166
+
167
+ export { VIOLATION_EXIT, VIOLATION_PRIORITY };
168
+
169
+ // ── 分类 ────────────────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * 内核错误的 `name` → 退出码。
173
+ *
174
+ * 🔴 `Corrupt` 落 **5**,不落 2 —— 判据不是我们的猜测,是**内核自己写死的**:
175
+ * `journal.mjs` 的 `Corrupt` 构造器里就是 `this.code = 5`,
176
+ * 而 11-wire-contract.md §5 对 journal CRC 失败要求「停机,报告为需要人工介入」。
177
+ * §6 第 2 条(完整性失败)说的是验签 / 摘要 / 资产 sha256 / 签名身份 ——
178
+ * 那些来自 `trust.mjs` 与 `untar.mjs`,不来自 `Corrupt`。
179
+ *
180
+ * 🔴 但 `Corrupt` 被 journal / ledger / plan / lockfile **共用**,
181
+ * 单凭错误类分不出来源。因此凡是「这里抛的 Corrupt 其实是另一档」的地方,
182
+ * 都在**调用点**显式包装成 `ConflictError` / integrity 错,
183
+ * **绝不**靠对错误文案做正则。已经这么做的两处:
184
+ * · `commands/install.mjs`:未认领同名目录 → `ConflictError`(3);
185
+ * · lockfile 的结构 / 闭包校验 → 由调用点包成完整性失败(2)。
186
+ */
187
+ const BY_NAME = Object.freeze({
188
+ // trust.mjs
189
+ // 🔴 `WireError` 落 **1**,不落 2 —— 内核构造器里写死的就是 `this.code = 1`
190
+ // 并注明「解析失败」,而 §6 第 1 条正是「用法错误 / **解析失败** / 候选歧义」。
191
+ // §6 第 2 条列举的是验签失败 / 摘要不符 / 算法不认识 / 资产 sha256 不符 /
192
+ // 签名身份不对 —— 那些是 `IntegrityError` 与 `TarViolation`。
193
+ WireError: EXIT.USAGE,
194
+ IntegrityError: EXIT.INTEGRITY,
195
+ StaleError: EXIT.STALE,
196
+ MinCliVersionError: EXIT.MIN_CLI,
197
+ // untar.mjs / artifact.mjs
198
+ TarViolation: EXIT.INTEGRITY,
199
+ // journal.mjs(ledger / plan / lockfile / install / recover 共用)
200
+ Corrupt: EXIT.NEEDS_RECOVER,
201
+ // lock.mjs
202
+ LockBusyError: EXIT.NEEDS_RECOVER,
203
+ // recover.mjs
204
+ NeedsRecover: EXIT.NEEDS_RECOVER,
205
+ });
206
+
207
+ /**
208
+ * 把任意异常映射成退出码。
209
+ *
210
+ * @param {unknown} err
211
+ * @param {object} [o]
212
+ * @param {number} [o.preferCode]
213
+ * 调用方已经知道这个错该落哪一档时给它(例如 recover 的分流拒绝 → 5)。
214
+ * 🔴 只在**认得出**这个错的前提下用;它压不过 `err.exitCode`。
215
+ * @returns {{code:number, unclassified:boolean, reason:string}}
216
+ * `reason` 是给埋点用的 `REASONS` 代码,`null` 表示没有合适的代码。
217
+ */
218
+ export function classify(err, o = {}) {
219
+ // ① 我们自己抛的:码写在对象上,最权威
220
+ if (err && Number.isInteger(err.exitCode)) {
221
+ return { code: err.exitCode, unclassified: false, reason: reasonFor(err, err.exitCode) };
222
+ }
223
+ // ② 预检聚合错:带 violations 数组
224
+ if (err && Array.isArray(err.violations) && err.violations.length) {
225
+ const code = exitForViolations(err.violations);
226
+ return { code, unclassified: false, reason: reasonFor(err, code) };
227
+ }
228
+ // ③ 内核错误按 name
229
+ const named = err && BY_NAME[err.name];
230
+ if (named !== undefined) {
231
+ const code = o.preferCode !== undefined && named === EXIT.INTEGRITY ? o.preferCode : named;
232
+ return { code, unclassified: false, reason: reasonFor(err, code) };
233
+ }
234
+ // ④ lock.mjs 的 LockBusyError 万一被跨实例包装过:它带 code === 5 与 holder
235
+ if (err && err.code === 5 && Object.hasOwn(err, 'holder')) {
236
+ return { code: EXIT.NEEDS_RECOVER, unclassified: false, reason: 'lock-busy' };
237
+ }
238
+ // ⑤ 认不出来 —— 绝不给 0
239
+ return { code: UNCLASSIFIED, unclassified: true, reason: 'unknown' };
240
+ }
241
+
242
+ /**
243
+ * 埋点的 `reason`:🔴 必须来自 `telemetry.REASONS` 那张**有限代码表**。
244
+ * 这里只挑得出代码,挑不出就返回 `'unknown'`(它也在表里)——
245
+ * **绝不**把错误文案塞进 reason(那正是 REASONS 存在的理由)。
246
+ */
247
+ function reasonFor(err, code) {
248
+ const name = err?.name;
249
+ if (name === 'LockBusyError') return 'lock-busy';
250
+ if (name === 'StaleError') return 'unknown'; // REASONS 里没有 stale 这一条
251
+ if (name === 'MinCliVersionError') return 'unknown'; // 同上
252
+ if (name === 'NeedsRecover') return 'journal-corrupt';
253
+ if (name === 'TarViolation') return 'digest-mismatch';
254
+ if (err?.telemetryReason) return err.telemetryReason; // 调用方点名的代码(自己保证在表里)
255
+ switch (code) {
256
+ case EXIT.INTEGRITY: return 'digest-mismatch';
257
+ case EXIT.NETWORK: return 'network-error';
258
+ case EXIT.AUTH: return 'unknown';
259
+ case EXIT.NOT_WRITABLE: return 'target-not-writable';
260
+ case EXIT.UNSUPPORTED: return 'unsupported-client';
261
+ case EXIT.CONFLICT: return 'version-conflict';
262
+ case EXIT.USAGE: return 'unknown';
263
+ default: return 'unknown';
264
+ }
265
+ }
@@ -0,0 +1,379 @@
1
+ // 故障注入内核 —— M0 §6 P0 第 3 项。
2
+ //
3
+ // 规格依据:docs/m0/00-decisions.md §6、04-install.md §5.2/5.2.1/5.3/5.4/5.6/5.8/5.10、
4
+ // 11-wire-contract.md §5。
5
+ //
6
+ // 设计三条:
7
+ // 1. **注入点有名字,不按序号。** 「第 N 次 write」在代码改动后会静默错位,
8
+ // 指到完全不同的操作上,测试照样绿。名字改了则 CATALOG 交叉核对会失败(可发现)。
9
+ // 2. **框架能枚举注入点。** 无故障跑一趟拿到 trace,再对 trace 里的每一项各崩一次;
10
+ // 测试不是手写几十个 case,而是「对这个事务的每一个注入点各跑一遍」。
11
+ // 3. **未武装时零开销。** ACTIVE 为 false 时 fp() 第一行就 return。
12
+ //
13
+ // 🔴 各模式**能证明什么、不能证明什么**,见本文件末尾的 MODES 表与 README 段。
14
+ // 特别是:**SIGKILL 不证明持久性**。
15
+
16
+ import {
17
+ appendFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+
22
+ // ── 模式 ─────────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * 每种崩溃模式**证明什么 / 不证明什么**。测试报告直接引用这张表,
26
+ * 免得「跑了 200 个注入点」被当成「持久性已证明」。
27
+ */
28
+ export const MODES = {
29
+ throw: {
30
+ proves: '调用栈在这一点中断,后续步骤没跑;catch/finally 路径不会把状态改坏。',
31
+ disproves_not:
32
+ '不证明进程真死时的行为 —— finally、进程退出钩子、SQLite 的 close 都仍会跑。',
33
+ },
34
+ exit: {
35
+ proves: 'process.exit():finally 与 catch 都不跑,只有 exit 钩子跑。',
36
+ disproves_not: '不证明信号/内核级中止;exit 钩子仍有机会写盘。',
37
+ },
38
+ kill: {
39
+ proves:
40
+ 'SIGKILL:进程内**任何**收尾代码都不可能跑(无 finally、无 atexit、无信号处理器)。' +
41
+ '证明的是「时序」—— 恢复逻辑必须能从「第 k 步之后、第 k+1 步之前」接上。',
42
+ disproves_not:
43
+ '🔴 **不证明持久性。** POSIX 下 write() 的数据在内核页缓存里,进程被杀不会丢;' +
44
+ '只有掉电/内核崩溃才会丢。因此「SIGKILL 后恢复正常」**不能**推出「fsync 用对了」。' +
45
+ '想证明 fsync 用对了,用 powerfail 模式(近似)或块设备层工具(dm-log-writes,非本框架范围)。',
46
+ },
47
+ errno: {
48
+ proves:
49
+ '以 EIO/ENOSPC 失败并把控制权交回调用方 —— 证明 §5.4「I/O 失败统一规则」的 ' +
50
+ 'fail-closed:不推进 journal、不当成功、不吞错。\n' +
51
+ '🔴 **两种失败语义靠注入点位置区分,不靠模式**:\n' +
52
+ ' · 打在 `…:pre-X` 上 = 「syscall 失败且没有副作用」;\n' +
53
+ ' · 打在 `…:post-X` 上 = 「syscall 已生效但随后报错」—— 这正是 §11 §5 说的\n' +
54
+ ' 「rename 已经生效、而随后的父目录 fsync 报错时,目标文件**可能已经存在**」,\n' +
55
+ ' 规范据此禁止任何「写失败 → 磁盘未变」的说法。',
56
+ disproves_not: '不证明真实设备上的错误传播(EIO 之后 fd 状态、page 是否已回写)。',
57
+ },
58
+ powerfail: {
59
+ proves:
60
+ '掉电近似:把「已发生但其所在目录/文件尚未 fsync」的效果**撤销**,再 SIGKILL。' +
61
+ '这是唯一能打到 §5.2.1「两棵都丢」那个反例的模式 —— ' +
62
+ '只 fsync 叶子时,上层目录项没落盘,断电后新建的 tx 根连同两棵树一起消失。',
63
+ disproves_not:
64
+ '🔴 这是 **API 边界上的仿真**,不是块设备层的。不建模:写入乱序、页内撕裂(部分写)、' +
65
+ '文件系统自身的日志语义、以及**任何绕过 atomic-fs 的裸 fs 调用**(那些效果对它不可见)。',
66
+ },
67
+ };
68
+
69
+ export class FaultInjected extends Error {
70
+ constructor(name, nth, mode) {
71
+ super(`fault-inject: ${name} #${nth} (mode=${mode})`);
72
+ this.name = 'FaultInjected';
73
+ this.faultPoint = name;
74
+ this.nth = nth;
75
+ this.mode = mode;
76
+ }
77
+ }
78
+
79
+ // ── 状态 ─────────────────────────────────────────────────────────────────────
80
+
81
+ let ACTIVE = false; // ARMED || TRACING —— fp() 的快速守卫
82
+ let ARMED = false;
83
+ let LOCKED = false; // lockdown() 之后永久不可武装(生产入口调用)
84
+ let TARGET = null;
85
+ let NTH = 1;
86
+ let MODE = 'throw';
87
+ let ERRNO = 'EIO';
88
+ let TRACE_PATH = null;
89
+ const counts = new Map();
90
+ const hits = []; // 本进程命中过的 (name, nth),供进程内测试断言
91
+
92
+ function recompute() {
93
+ ACTIVE = ARMED || TRACE_PATH !== null;
94
+ }
95
+
96
+ // ── 注入点探针 ───────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * 具名注入点。埋在**每一个写操作的前后**,名字形如 `atomic-write:pre-rename`。
100
+ * @param {string} name 必须出现在 test/harness/fault-points.mjs 的 CATALOG 里
101
+ * (test/fault-matrix.test.mjs 做三向交叉核对)
102
+ * @param {object} [ctx] 诊断上下文,只进 trace,不参与判定
103
+ */
104
+ export function fp(name, ctx) {
105
+ if (!ACTIVE) return;
106
+ const n = (counts.get(name) ?? 0) + 1;
107
+ counts.set(name, n);
108
+ // 🔴 trace 本身绝不能改变被测操作的语义:写 trace 失败、ctx 不可序列化,
109
+ // 都只能被吞掉,不能变成被测代码看到的异常。
110
+ if (TRACE_PATH !== null) {
111
+ let extra = '';
112
+ try { extra = ctx ? JSON.stringify(ctx) : ''; } catch { extra = '<unserializable>'; }
113
+ try { appendFileSync(TRACE_PATH, `${name}\t${n}\t${extra}\n`); } catch { /* 见上 */ }
114
+ }
115
+ if (!ARMED || name !== TARGET || n !== NTH) return;
116
+ hits.push({ name, nth: n });
117
+ return detonate(name, n, ctx);
118
+ }
119
+
120
+ function detonate(name, nth, ctx) {
121
+ switch (MODE) {
122
+ case 'throw':
123
+ throw new FaultInjected(name, nth, MODE);
124
+ case 'errno': {
125
+ const e = new FaultInjected(name, nth, MODE);
126
+ e.code = ERRNO;
127
+ e.errno = -5;
128
+ e.syscall = ctx?.syscall ?? 'write';
129
+ throw e;
130
+ }
131
+ case 'exit':
132
+ // 97 = 「这是注入的崩溃」,与任何真实退出码区分开
133
+ process.exit(97);
134
+ break;
135
+ case 'powerfail':
136
+ applyPowerFailure();
137
+ process.kill(process.pid, 'SIGKILL');
138
+ break;
139
+ case 'kill':
140
+ process.kill(process.pid, 'SIGKILL');
141
+ break;
142
+ default:
143
+ throw new Error(`fault-inject: 未知模式 ${MODE}`);
144
+ }
145
+ // SIGKILL 在返回用户态时投递,实践上是同步的;但不挡一下的话,
146
+ // 万一没落地,被注入的那一步会**继续执行**,注入点就错位了。
147
+ // 有界忙等(2s)之后退而求其次用 exit,避免测试套件真的悬挂。
148
+ if (MODE === 'kill' || MODE === 'powerfail') {
149
+ const until = Date.now() + 2000;
150
+ while (Date.now() < until) {
151
+ /* 等信号落地 */
152
+ }
153
+ process.exit(97);
154
+ }
155
+ }
156
+
157
+ // ── 武装 / 解除 ──────────────────────────────────────────────────────────────
158
+
159
+ export function arm({ name, nth = 1, mode = 'throw', errno = 'EIO' } = {}) {
160
+ if (LOCKED) throw new Error('fault-inject: 已 lockdown,不可武装');
161
+ if (!name) throw new Error('fault-inject: arm 需要 name');
162
+ if (!(mode in MODES)) throw new Error(`fault-inject: 未知模式 ${mode}`);
163
+ ARMED = true;
164
+ TARGET = name;
165
+ NTH = Number(nth);
166
+ MODE = mode;
167
+ ERRNO = errno;
168
+ recompute();
169
+ }
170
+
171
+ export function disarm() {
172
+ ARMED = false;
173
+ TARGET = null;
174
+ recompute();
175
+ }
176
+
177
+ /** 生产入口(bin/)应调用它:此后 env 与 arm() 都无法再打开注入。 */
178
+ export function lockdown() {
179
+ LOCKED = true;
180
+ ARMED = false;
181
+ TARGET = null;
182
+ TRACE_PATH = null;
183
+ pending.length = 0;
184
+ recompute();
185
+ }
186
+
187
+ export function setTrace(path) {
188
+ if (LOCKED) throw new Error('fault-inject: 已 lockdown');
189
+ TRACE_PATH = path;
190
+ recompute();
191
+ }
192
+
193
+ export function reset() {
194
+ counts.clear();
195
+ hits.length = 0;
196
+ pending.length = 0;
197
+ }
198
+
199
+ export function observed() {
200
+ return hits.slice();
201
+ }
202
+
203
+ export function hitCount(name) {
204
+ return counts.get(name) ?? 0;
205
+ }
206
+
207
+ export function state() {
208
+ return { ACTIVE, ARMED, LOCKED, TARGET, NTH, MODE, TRACE_PATH };
209
+ }
210
+
211
+ /** 生产热路径用它跳过只为掉电模型服务的额外 I/O */
212
+ export function shadowActive() {
213
+ return ACTIVE;
214
+ }
215
+
216
+ /**
217
+ * 从环境读配置。🔴 **必须由调用方显式调用** —— 本模块**不在 import 时自动调用**。
218
+ * 子进程崩溃测试在 test/harness/child.mjs 里显式调它。
219
+ *
220
+ * 🔴 **双钥匙**:还必须有 `GEOLY_FAULT_ENABLE=1`。
221
+ * 单一变量太容易被误设/继承,而它能让生产 CLI 在真实 target 上崩溃。
222
+ *
223
+ * GEOLY_FAULT_ENABLE 必须是 "1",否则以下全部忽略
224
+ * GEOLY_FAULT 注入点名
225
+ * GEOLY_FAULT_NTH 第几次命中(默认 1)
226
+ * GEOLY_FAULT_MODE throw|exit|kill|errno|powerfail(默认 throw)
227
+ * GEOLY_FAULT_ERRNO errno 模式用的 code(默认 EIO)
228
+ * GEOLY_FAULT_TRACE trace 落点文件
229
+ * 🔴 名字里有冒号,所以**不做 `name:mode` 拼接**,各占一个变量。
230
+ */
231
+ export function armFromEnv(env = process.env) {
232
+ if (LOCKED) return;
233
+ if (env.GEOLY_FAULT_ENABLE !== '1') return;
234
+ if (env.GEOLY_FAULT_TRACE) setTrace(env.GEOLY_FAULT_TRACE);
235
+ if (env.GEOLY_FAULT) {
236
+ arm({
237
+ name: env.GEOLY_FAULT,
238
+ nth: env.GEOLY_FAULT_NTH ? Number(env.GEOLY_FAULT_NTH) : 1,
239
+ mode: env.GEOLY_FAULT_MODE ?? 'throw',
240
+ errno: env.GEOLY_FAULT_ERRNO ?? 'EIO',
241
+ });
242
+ }
243
+ }
244
+
245
+ // ── 持久性影子(powerfail 模式)─────────────────────────────────────────────
246
+ //
247
+ // atomic-fs 每做一次「尚未持久」的改动就登记一条 undo;对应的 fsync 一旦成功
248
+ // 就把它划掉。powerfail = 逆序执行还没划掉的 undo,然后 SIGKILL。
249
+ //
250
+ // 🔴 只覆盖走 atomic-fs 的改动。裸 fs 调用对它不可见 —— 这是仿真的边界,不是 bug。
251
+
252
+ // 每条 pending:{ dirs: Set<string>, files: Set<string>, tag, undo() }
253
+ // 「dirs 与 files 都被 fsync 过」才算持久,才从表里划掉。
254
+ const pending = [];
255
+
256
+ let POWERFAIL_STYLE = process.env.GEOLY_FAULT_POWERFAIL_STYLE ?? 'drop';
257
+
258
+ export function setPowerfailStyle(s) {
259
+ POWERFAIL_STYLE = s;
260
+ }
261
+
262
+ /** 登记「`path` 这个目录项刚出现,但 `dirname(path)` 还没 fsync」 */
263
+ export function pendingCreate(path) {
264
+ if (!ACTIVE) return;
265
+ pending.push({
266
+ dirs: new Set([dirname(path)]),
267
+ files: new Set(),
268
+ tag: `create ${path}`,
269
+ undo: () => rmSync(path, { recursive: true, force: true }),
270
+ });
271
+ }
272
+
273
+ /**
274
+ * 登记「`from` → `to` 的 rename 刚发生」。
275
+ * 🔴 rename 动两个目录项:源目录里少一个、目标目录里多一个。
276
+ * **两侧父目录都 fsync 过**才算持久。
277
+ * 掉电后的两种合法结果,本模型二选一(GEOLY_FAULT_POWERFAIL_STYLE):
278
+ * · `drop` —— 当作 rename 没发生(把 `to` 搬回 `from`);
279
+ * · `duplicate` —— 目标侧目录项落盘、源侧删除未落盘 ⇒ **两边都在**。
280
+ * 这正是 §5.4 幂等表里判 corrupt 的分支 ②,值得单独打。
281
+ */
282
+ export function pendingRename(from, to, overwrittenBytes) {
283
+ if (!ACTIVE) return;
284
+ pending.push({
285
+ dirs: new Set([dirname(to), dirname(from)]),
286
+ files: new Set(),
287
+ tag: `rename ${from} -> ${to}`,
288
+ undo: () => {
289
+ if (!existsSync(to) || existsSync(from)) return;
290
+ if (POWERFAIL_STYLE === 'duplicate') { cpSync(to, from, { recursive: true }); return; }
291
+ renameSync(to, from);
292
+ // 🔴 rename **覆盖**了一个已存在的目标时,「这次 rename 没发生」意味着
293
+ // 旧目标还在。不把它写回去,撤销结果就不是一个合法的掉电分支。
294
+ if (overwrittenBytes !== undefined) writeFileSync(to, overwrittenBytes);
295
+ },
296
+ });
297
+ }
298
+
299
+ /**
300
+ * 登记「`path` 刚被删,父目录还没 fsync」。
301
+ * 🔴 undo 会把它**放回去** —— 删除未落盘时掉电,目录项还在。
302
+ * 这一条是 §5.6 阶段 C「删到一半」那个窗口的唯一建模途径;
303
+ * 以前写成空函数时,powerfail 在这一格是**假绿**(Codex 第二轮 #10)。
304
+ * @param {Buffer|null} preimage 文件内容前像;目录传 null
305
+ * @param {number} mode
306
+ * @param {boolean} [isDir]
307
+ */
308
+ export function pendingUnlink(path, preimage = null, mode = 0o644, isDir = false) {
309
+ if (!ACTIVE) return;
310
+ pending.push({
311
+ dirs: new Set([dirname(path)]),
312
+ files: new Set(),
313
+ tag: `unlink ${path}`,
314
+ undo: () => {
315
+ if (existsSync(path)) return;
316
+ if (isDir) { mkdirSync(path, mode); return; }
317
+ if (preimage !== null) writeFileSync(path, preimage, { mode });
318
+ },
319
+ });
320
+ }
321
+
322
+ /** 登记「`path` 的数据已 write,但文件自身与父目录都还没 fsync」 */
323
+ export function pendingData(path) {
324
+ if (!ACTIVE) return;
325
+ pending.push({
326
+ dirs: new Set([dirname(path)]),
327
+ files: new Set([path]),
328
+ tag: `data ${path}`,
329
+ undo: () => {
330
+ try {
331
+ if (existsSync(path) && statSync(path).isFile()) unlinkSync(path);
332
+ } catch {
333
+ /* 掉电模型下这一步失败无所谓 */
334
+ }
335
+ },
336
+ });
337
+ }
338
+
339
+ function sweep() {
340
+ for (let i = pending.length - 1; i >= 0; i--) {
341
+ if (pending[i].dirs.size === 0 && pending[i].files.size === 0) pending.splice(i, 1);
342
+ }
343
+ }
344
+
345
+ /** 某个目录 fsync 成功 */
346
+ export function durableDir(dir) {
347
+ if (!ACTIVE) return;
348
+ for (const p of pending) p.dirs.delete(dir);
349
+ sweep();
350
+ }
351
+
352
+ /** 某个文件 fsync 成功(它的目录项可能仍未持久 —— 那部分靠 durableDir 划) */
353
+ export function durableFile(file) {
354
+ if (!ACTIVE) return;
355
+ for (const p of pending) p.files.delete(file);
356
+ sweep();
357
+ }
358
+
359
+ export function pendingEffects() {
360
+ return pending.map((p) => p.tag);
361
+ }
362
+
363
+ /** 逆序撤销全部未持久效果 */
364
+ export function applyPowerFailure() {
365
+ for (let i = pending.length - 1; i >= 0; i--) {
366
+ try {
367
+ pending[i].undo();
368
+ } catch {
369
+ /* 掉电不会报错 */
370
+ }
371
+ }
372
+ pending.length = 0;
373
+ }
374
+
375
+ // 🔴 **刻意不在 import 时自动调用 armFromEnv()**(Codex 第二轮 P0-1)。
376
+ // 生产入口 bin/skills-hub.mjs 在 import 之后才有机会 lockdown(),ESM 下已经太晚 ——
377
+ // 只要模块一加载就读 env,用户环境里的 GEOLY_FAULT_ENABLE=1 就能让生产 CLI
378
+ // 在真实 target 上 SIGKILL,GEOLY_FAULT_TRACE 还能让它往任意路径追加内容。
379
+ // 改为**必须显式初始化**:测试 harness(test/harness/child.mjs)自己调 armFromEnv()。