@xiciyang/rsi-contract 0.0.0-stage → 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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * rollback-decision.mjs —— 能力 ⑤「回退决策」。
3
+ *
4
+ * 输入:故障态 + 版本历史(已知良好状态及其产物标识)。
5
+ * 输出:`{ target, path, verification }` —— 回到哪个状态、怎么回、**怎么核验它真的生效了**。
6
+ *
7
+ * ─────────────────────────── 三条硬纪律 ───────────────────────────
8
+ *
9
+ * ① **没有可信目标就拒绝**。只有"`verifiedGood === true` **且**有 `verifiedBy` **且**产物带内容哈希"
10
+ * 的既往状态才算可信。一条都没有 ⇒ `refused: true` + `E_NO_ROLLBACK_TARGET`。**不猜最近的那个。**
11
+ *
12
+ * ② **核验必须落在产物上**(本仓库原文:"只断言过程抓不到空操作,必须断言权重")。
13
+ * `verification.kind` 只允许 `artifact-content-hash`,`comparator` 只允许 `sha256-equal`。
14
+ * 调用方若自带"看退出码就算回退成功"的方案 ⇒ `E_PROCESS_ONLY_VERIFICATION` 拒绝。
15
+ * 每个 check 都带上 `notBeforeSha256`(故障态哈希):**哈希没变就是空操作**,必须判失败。
16
+ *
17
+ * ③ **时间戳不参与决策**。排序只按 `seq`(缺失则按输入数组下标,并在 `orderingBasis` 里标明)。
18
+ * 这就是确定性要求的具体形态:同一组状态,不管 `verifiedAt` 怎么改,回退目标不变。
19
+ *
20
+ * ## 三个失败模式怎么被检出
21
+ * - 回退失败:无可信目标(决策期)/observed 与目标哈希不符(核验期);
22
+ * - 回退到错误版本:observed 哈希能归属到**别的**历史状态 ⇒ 指名报出是哪一个;
23
+ * - 回退不彻底:目标未覆盖故障态的全部产物(决策期即排除)/observed 缺某个必需产物的哈希(核验期)。
24
+ */
25
+
26
+ import { CAPABILITY_VERSION } from './version.mjs'
27
+ import { assertPlainObject, assertArray, optionalArray, isContentHash, nonEmptyString, uniqSorted, jsonClone, RsiLoopError } from './support.mjs'
28
+ import { VERIFICATION_COMPARATORS } from './contract.mjs'
29
+
30
+ export const CAPABILITY_ID = 'rollbackDecision'
31
+
32
+ export const VERIFICATION_CRITERION = '回退后逐产物比对 sha256:必须等于目标状态登记的内容哈希(notBeforeSha256 相同的产物即空操作,判失败)。过程断言(退出码/日志/服务可用)只作补充,**不构成核验**。'
33
+
34
+ export function rollbackDecision(input) {
35
+ const arg = assertPlainObject(input ?? {}, 'rollbackDecision 入参', 'E_INPUT_SHAPE')
36
+ const failure = assertPlainObject(arg.failure ?? null, 'failure', 'E_INPUT_SHAPE')
37
+ const history = assertArray(arg.history ?? null, 'history', 'E_INPUT_SHAPE')
38
+
39
+ const failureArtifacts = collectArtifacts(failure.artifacts, 'failure.artifacts')
40
+ const failureHashes = hashesOf(failureArtifacts)
41
+ const refs = uniqSorted([
42
+ ...(Array.isArray(failure.evidenceRefs) ? failure.evidenceRefs.filter(nonEmptyString) : []),
43
+ ...history.flatMap((h) => (h && Array.isArray(h.evidenceRefs) ? h.evidenceRefs.filter(nonEmptyString) : [])),
44
+ ])
45
+
46
+ const base = {
47
+ capability: CAPABILITY_ID,
48
+ version: CAPABILITY_VERSION,
49
+ target: null,
50
+ path: null,
51
+ verification: null,
52
+ refused: true,
53
+ code: null,
54
+ reasons: [],
55
+ evidence: { failureHashes, considered: [] },
56
+ evidenceRefs: refs,
57
+ }
58
+
59
+ // ── 故障态必须有哈希,否则连"空操作"都抓不到 ──
60
+ if (failureArtifacts.length === 0) {
61
+ return { ...base, code: 'E_ARTIFACT_SET_MISMATCH', reasons: ['failure.artifacts 为空或没有任何带合法 sha256 的产物 ⇒ 无法核验回退是否生效(fail-closed)'] }
62
+ }
63
+
64
+ // ── 调用方自带的核验方案:只允许产物级 ──
65
+ if (arg.proposedVerification !== undefined && arg.proposedVerification !== null) {
66
+ const pv = arg.proposedVerification
67
+ const ok =
68
+ pv && typeof pv === 'object' && !Array.isArray(pv) &&
69
+ pv.kind === 'artifact-content-hash' &&
70
+ Array.isArray(pv.checks) && pv.checks.length > 0 &&
71
+ pv.checks.every((c) => c && VERIFICATION_COMPARATORS.includes(c.comparator) && isContentHash(c.expectSha256))
72
+ if (!ok) {
73
+ return {
74
+ ...base,
75
+ code: 'E_PROCESS_ONLY_VERIFICATION',
76
+ reasons: ['proposedVerification 不是产物级核验(须 kind=artifact-content-hash、comparator=sha256-equal、每条 check 带 expectSha256)⇒ 只断言过程的方案抓不到空操作,拒绝'],
77
+ }
78
+ }
79
+ }
80
+
81
+ // ── 排序依据:优先 seq(全整数才算齐备),否则下标。时间戳永不参与。 ──
82
+ const allHaveSeq = history.length > 0 && history.every((h) => Number.isInteger(h?.seq))
83
+ const orderingBasis = allHaveSeq ? 'seq' : 'array-index'
84
+ if (!allHaveSeq && history.some((h) => Number.isInteger(h?.seq))) {
85
+ base.reasons.push('历史里 seq 不完整 ⇒ 退化为按输入数组下标排序(orderingBasis=array-index,不静默)')
86
+ }
87
+
88
+ // ── 逐候选判定 ──
89
+ const considered = []
90
+ const eligible = []
91
+ history.forEach((h, idx) => {
92
+ const stateId = h && nonEmptyString(h.stateId) ? h.stateId : `(下标 ${idx})`
93
+ const reject = (code, reason) => considered.push({ stateId, hashes: {}, eligible: false, code, reason })
94
+ if (!h || typeof h !== 'object' || Array.isArray(h)) return reject('E_INPUT_SHAPE', '历史项不是对象')
95
+ // ① 可信性:verifiedGood + verifiedBy 二者缺一不可
96
+ if (h.verifiedGood !== true) return reject('E_TARGET_NOT_VERIFIED_GOOD', '未声明 verifiedGood=true ⇒ 不是已验证良好的状态')
97
+ if (!nonEmptyString(h.verifiedBy)) return reject('E_TARGET_NOT_VERIFIED_GOOD', 'verifiedGood=true 但没有 verifiedBy ⇒ "谁证明了它 good"缺失,未知 ≠ 已知良好')
98
+ const arts = tryArtifacts(h.artifacts)
99
+ if (arts === null) return reject('E_ARTIFACT_SET_MISMATCH', '产物缺合法内容哈希 ⇒ 无法核验,不能作为回退目标')
100
+ if (arts.length === 0) return reject('E_ARTIFACT_SET_MISMATCH', '产物列表为空 ⇒ 无可核验对象')
101
+ const hashes = hashesOf(arts)
102
+ // ② 必须覆盖故障态的全部产物(否则回退不彻底)
103
+ const missing = failureArtifacts.map((a) => a.name).filter((n) => !(n in hashes))
104
+ if (missing.length > 0) {
105
+ return considered.push({ stateId, hashes, eligible: false, code: 'E_ARTIFACT_SET_MISMATCH', reason: `未覆盖故障态产物 [${missing.join(', ')}] ⇒ 回退会不彻底,不予采纳` })
106
+ }
107
+ // ③ 全等 ⇒ 它不是一个"先前"状态,回退等于空操作
108
+ const identical = failureArtifacts.every((a) => hashes[a.name] === failureHashes[a.name])
109
+ if (identical) {
110
+ return considered.push({ stateId, hashes, eligible: false, code: 'E_ROLLBACK_NOOP', reason: '全部必需产物的哈希与故障态相同 ⇒ 回退到它等于空操作' })
111
+ }
112
+ const seq = allHaveSeq ? h.seq : idx
113
+ considered.push({ stateId, hashes, eligible: true, code: null, reason: null })
114
+ eligible.push({ stateId, seq, index: idx, artifacts: arts, hashes, refs: Array.isArray(h.evidenceRefs) ? h.evidenceRefs.filter(nonEmptyString) : [] })
115
+ })
116
+
117
+ const sortedConsidered = [...considered].sort((a, b) => (a.stateId < b.stateId ? -1 : a.stateId > b.stateId ? 1 : 0))
118
+ const evidence = { failureHashes, considered: sortedConsidered }
119
+
120
+ if (eligible.length === 0) {
121
+ return {
122
+ ...base,
123
+ evidence,
124
+ code: 'E_NO_ROLLBACK_TARGET',
125
+ reasons: [...base.reasons, `历史里 ${history.length} 个状态无一满足"已验证良好 + 有哈希背书 + 覆盖故障态全部产物" ⇒ 无回退目标,拒绝(不猜最近的那个)`],
126
+ }
127
+ }
128
+
129
+ // ④ 取"上一个":seq 最大者;seq 相同按下标最大者(全序)
130
+ eligible.sort((a, b) => (a.seq !== b.seq ? b.seq - a.seq : b.index - a.index))
131
+ const pick = eligible[0]
132
+
133
+ const steps = failureArtifacts
134
+ .map((a) => ({ op: 'restore', artifact: a.name, from: pick.stateId, expectSha256: pick.hashes[a.name] }))
135
+ .sort((x, y) => (x.artifact < y.artifact ? -1 : x.artifact > y.artifact ? 1 : 0))
136
+
137
+ const checks = failureArtifacts
138
+ .map((a) => ({ artifact: a.name, expectSha256: pick.hashes[a.name], observedFrom: 'artifact-content-hash(回退后现场重算)', notBeforeSha256: failureHashes[a.name] }))
139
+ .sort((x, y) => (x.artifact < y.artifact ? -1 : x.artifact > y.artifact ? 1 : 0))
140
+
141
+ return {
142
+ ...base,
143
+ refused: false,
144
+ code: null,
145
+ target: { stateId: pick.stateId, seq: pick.seq, artifacts: jsonClone(pick.artifacts, 'target.artifacts') },
146
+ path: { kind: 'restore-artifacts', orderingBasis, steps },
147
+ verification: { kind: 'artifact-content-hash', comparator: 'sha256-equal', checks, criterion: VERIFICATION_CRITERION, rejectsProcessOnly: true },
148
+ reasons: [
149
+ ...base.reasons,
150
+ `回退目标 ${pick.stateId}(排序依据 ${orderingBasis},seq=${pick.seq};时间戳未参与决策)`,
151
+ `被排除的候选:${sortedConsidered.filter((c) => !c.eligible).map((c) => `${c.stateId}(${c.code})`).join(', ') || '无'}`,
152
+ ],
153
+ evidence,
154
+ evidenceRefs: uniqSorted([...refs, ...pick.refs]),
155
+ }
156
+ }
157
+
158
+ /**
159
+ * 核验回退是否**真的**生效(产物级)。
160
+ *
161
+ * @param {{decision:object, observed:object}} p
162
+ * `observed` = **别处**产出的现场观测:`{ artifacts: [{name, sha256}], exitCode?, producedBy? }`。
163
+ * 只有过程证据(退出码/日志)而没有哈希 ⇒ 核验失败。
164
+ */
165
+ export function verifyRollbackDecision({ decision, observed } = {}) {
166
+ const d = assertPlainObject(decision ?? null, 'decision', 'E_INPUT_SHAPE')
167
+ const o = assertPlainObject(observed ?? null, 'observed', 'E_INPUT_SHAPE')
168
+
169
+ if (o.producedBy === CAPABILITY_ID) {
170
+ return { ok: false, code: 'E_SELF_JUDGING', checks: [], reasons: [`observed.producedBy=${CAPABILITY_ID} ⇒ 用本能力的产物自证回退生效,不算证据`] }
171
+ }
172
+ if (!d || d.refused === true || !d.verification) {
173
+ return { ok: false, code: d?.code ?? 'E_NO_ROLLBACK_TARGET', checks: [], reasons: ['决策本身未产出回退方案(refused)⇒ 无可核验内容'] }
174
+ }
175
+
176
+ const obsArts = Array.isArray(o.artifacts) ? o.artifacts.filter((a) => a && nonEmptyString(a.name) && isContentHash(a.sha256)) : []
177
+ if (obsArts.length === 0) {
178
+ return {
179
+ ok: false,
180
+ code: 'E_PROCESS_ONLY_VERIFICATION',
181
+ checks: [],
182
+ reasons: ['observed 里没有任何带合法 sha256 的产物 ⇒ 只有过程证据(退出码/日志)⇒ 过程断言抓不到空操作,核验失败(这正是"回退失败"最隐蔽的形态)'],
183
+ }
184
+ }
185
+ const obsMap = hashesOf(obsArts)
186
+ const failureHashes = d.evidence?.failureHashes ?? {}
187
+ const considered = Array.isArray(d.evidence?.considered) ? d.evidence.considered : []
188
+
189
+ const checks = []
190
+ for (const c of d.verification.checks) {
191
+ const got = obsMap[c.artifact]
192
+ if (got === undefined) {
193
+ checks.push({ artifact: c.artifact, expect: c.expectSha256, observed: null, ok: false, code: 'E_ROLLBACK_INCOMPLETE', reason: '未观测到该产物的哈希 ⇒ 回退不彻底(无法证明它回到目标版本)' })
194
+ continue
195
+ }
196
+ if (got === c.notBeforeSha256) {
197
+ checks.push({ artifact: c.artifact, expect: c.expectSha256, observed: got, ok: false, code: 'E_ROLLBACK_NOOP', reason: '哈希与回退前完全相同 ⇒ 空操作,回退未生效' })
198
+ continue
199
+ }
200
+ if (got !== c.expectSha256) {
201
+ const owner = considered.find((s) => s.hashes && s.hashes[c.artifact] === got)
202
+ checks.push({
203
+ artifact: c.artifact,
204
+ expect: c.expectSha256,
205
+ observed: got,
206
+ ok: false,
207
+ code: 'E_ROLLBACK_WRONG_VERSION',
208
+ reason: owner ? `观测到的哈希属于历史状态 ${owner.stateId} ⇒ 回退到了错误版本` : '观测到的哈希既不是目标版本也不是回退前版本 ⇒ 回退到了未知版本',
209
+ })
210
+ continue
211
+ }
212
+ checks.push({ artifact: c.artifact, expect: c.expectSha256, observed: got, ok: true, code: null, reason: null })
213
+ }
214
+
215
+ const failed = checks.filter((x) => !x.ok)
216
+ if (failed.length === 0) {
217
+ return { ok: true, code: null, checks, reasons: [`全部 ${checks.length} 个产物的 sha256 与目标状态一致 ⇒ 回退在产物层面生效(依据:${VERIFICATION_CRITERION})`] }
218
+ }
219
+ return { ok: false, code: failed[0].code, checks, reasons: failed.map((f) => `${f.code}: ${f.artifact} —— ${f.reason}`) }
220
+ }
221
+
222
+ function tryArtifacts(list) {
223
+ const arr = optionalArray(list, 'artifacts', 'E_INPUT_SHAPE')
224
+ const out = []
225
+ for (const a of arr) {
226
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return null
227
+ if (!nonEmptyString(a.name) || !isContentHash(a.sha256)) return null
228
+ out.push({ name: a.name, sha256: a.sha256 })
229
+ }
230
+ return out
231
+ }
232
+
233
+ function collectArtifacts(list) {
234
+ const arr = optionalArray(list, 'failure.artifacts', 'E_INPUT_SHAPE')
235
+ const out = []
236
+ for (const a of arr) {
237
+ if (!a || typeof a !== 'object' || Array.isArray(a)) continue
238
+ if (!nonEmptyString(a.name) || !isContentHash(a.sha256)) continue
239
+ out.push({ name: a.name, sha256: a.sha256 })
240
+ }
241
+ return out
242
+ }
243
+
244
+ function hashesOf(arts) {
245
+ const h = {}
246
+ for (const a of arts) h[a.name] = a.sha256
247
+ return h
248
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * support.mjs —— 纯函数支撑层(类型守卫 / 规范化 JSON / 确定性排序 / 小统计)。
3
+ *
4
+ * 三条纪律(沿用 state-core 的方言约定与四个插件的既有口径):
5
+ * ① **零运行时依赖**:不 import 任何包,也不用 `node:*`。需要文件系统时**注入**,
6
+ * 不在本层自行 import(见 state-core/src/dialect.mjs 的做法)。
7
+ * ② **不猜**:形状不对就响亮抛错(`RsiLoopError` + 稳定 code),不静默归一。
8
+ * ③ **确定性**:本层不引入任何时间、随机、环境相关量。排序一律是**全序**(有唯一 tiebreak),
9
+ * 否则同一输入的输出顺序会随运行时漂移。
10
+ */
11
+
12
+ /** 本层的错误类型:带**稳定 code**,使调用方能按 code 分支而不是按 message 文本。 */
13
+ export class RsiLoopError extends Error {
14
+ constructor(code, message, details = null) {
15
+ super(message)
16
+ this.name = 'RsiLoopError'
17
+ this.code = code
18
+ this.details = details
19
+ }
20
+ }
21
+
22
+ /** 方向词表(判定改进时用它归一符号;两个词表之外的值一律拒绝)。 */
23
+ export const DIRECTIONS = Object.freeze(['higher-is-better', 'lower-is-better'])
24
+
25
+ export function isPlainObject(v) {
26
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
27
+ const proto = Object.getPrototypeOf(v)
28
+ return proto === Object.prototype || proto === null
29
+ }
30
+
31
+ /** 结构性输入错误 → 抛错(数据性缺失 → 由各能力返回 refused/blocked 结果,不抛)。 */
32
+ export function assertPlainObject(v, name, code) {
33
+ if (!isPlainObject(v)) throw new RsiLoopError(code, `${name} 必须是普通对象(实得 ${describe(v)})`)
34
+ return v
35
+ }
36
+
37
+ export function assertArray(v, name, code) {
38
+ if (!Array.isArray(v)) throw new RsiLoopError(code, `${name} 必须是数组(实得 ${describe(v)})`)
39
+ return v
40
+ }
41
+
42
+ function describe(v) {
43
+ if (v === null) return 'null'
44
+ if (Array.isArray(v)) return `array(${v.length})`
45
+ return typeof v
46
+ }
47
+
48
+ /** 有限数判定 —— NaN / ±Infinity / 数字样字符串**都不算**(后者是"没测出来"的伪装)。 */
49
+ export function finiteNumber(v) {
50
+ return typeof v === 'number' && Number.isFinite(v)
51
+ }
52
+
53
+ export function nonEmptyString(v) {
54
+ return typeof v === 'string' && v.trim() !== ''
55
+ }
56
+
57
+ /** 内容哈希字面量(本仓库统一 `sha256:<64 hex>`;不认别的形态,含 `md5:`)。 */
58
+ export function isContentHash(v) {
59
+ return typeof v === 'string' && /^sha256:[0-9a-f]{64}$/.test(v)
60
+ }
61
+
62
+ export function directionOf(v) {
63
+ return DIRECTIONS.includes(v) ? v : null
64
+ }
65
+
66
+ /** 改进量 = 沿"越好越对"的方向看,after 比 before 好多少(正数 = 变好)。 */
67
+ export function improvementOf({ before, after, direction }) {
68
+ return direction === 'lower-is-better' ? before - after : after - before
69
+ }
70
+
71
+ export function mean(xs) {
72
+ if (!Array.isArray(xs) || xs.length === 0) return null
73
+ return xs.reduce((a, b) => a + b, 0) / xs.length
74
+ }
75
+
76
+ /** 中位数(偶数个取两中值平均)。**不排序入参**(不制造副作用)。 */
77
+ export function median(xs) {
78
+ if (!Array.isArray(xs) || xs.length === 0) return null
79
+ const s = [...xs].sort((a, b) => a - b)
80
+ const mid = Math.floor(s.length / 2)
81
+ return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2
82
+ }
83
+
84
+ export function uniqSorted(xs) {
85
+ return [...new Set(xs)].sort()
86
+ }
87
+
88
+ /**
89
+ * 规范化 JSON 文本 —— **确定性序列化**(对象键按码点排序,数组保序)。
90
+ *
91
+ * 存在的理由:`JSON.stringify` 的键序取决于**对象构造顺序**,
92
+ * 于是"同一输入两次运行输出是否相同"会被无关的构造顺序污染。
93
+ * 本库的确定性断言用它做指纹,不用 `JSON.stringify`。
94
+ * 非有限数一律抛错(`NaN`/`Infinity` 不是 JSON,静默写成 `null` 就是伪造证据)。
95
+ */
96
+ export function canonicalJson(value) {
97
+ return write(value, new Set())
98
+
99
+ function write(v, seen) {
100
+ if (v === null) return 'null'
101
+ const t = typeof v
102
+ if (t === 'boolean') return v ? 'true' : 'false'
103
+ if (t === 'number') {
104
+ if (!Number.isFinite(v)) throw new RsiLoopError('E_NON_FINITE_NUMBER', `规范化 JSON 拒绝非有限数:${String(v)}`)
105
+ return Object.is(v, -0) ? '0' : JSON.stringify(v)
106
+ }
107
+ if (t === 'string') return JSON.stringify(v)
108
+ if (t === 'function' || t === 'symbol' || t === 'bigint' || t === 'undefined') {
109
+ throw new RsiLoopError('E_NOT_JSON_SAFE', `规范化 JSON 拒绝 ${t} 值`)
110
+ }
111
+ if (seen.has(v)) throw new RsiLoopError('E_CYCLIC_REFERENCE', '规范化 JSON 检测到循环引用')
112
+ seen.add(v)
113
+ let out
114
+ if (Array.isArray(v)) out = `[${v.map((x) => write(x, seen)).join(',')}]`
115
+ else {
116
+ if (!isPlainObject(v)) throw new RsiLoopError('E_LIVE_OBJECT', `规范化 JSON 拒绝非普通对象(原型:${String(Object.getPrototypeOf(v)?.constructor?.name)})—— 产物不得嵌入活对象`)
117
+ out = `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${write(v[k], seen)}`).join(',')}}`
118
+ }
119
+ seen.delete(v)
120
+ return out
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 递归自证"可安全跨进程传输":无函数 / 无 undefined / 无非有限数 / 无活对象 / 无环。
126
+ * 返回 `{ ok, violations[] }`(**不抛**,因为它的用途正是把违规**报出来**)。
127
+ */
128
+ export function jsonSafeReport(value) {
129
+ const violations = []
130
+ const seen = new Set()
131
+ walk(value, '$')
132
+ return { ok: violations.length === 0, violations }
133
+
134
+ function walk(v, path) {
135
+ if (v === null) return
136
+ const t = typeof v
137
+ if (t === 'string' || t === 'boolean') return
138
+ if (t === 'number') {
139
+ if (!Number.isFinite(v)) violations.push(`${path}: 非有限数(${String(v)})`)
140
+ return
141
+ }
142
+ if (t !== 'object') {
143
+ violations.push(`${path}: ${t} 值不是 JSON 类型`)
144
+ return
145
+ }
146
+ if (seen.has(v)) {
147
+ violations.push(`${path}: 循环引用`)
148
+ return
149
+ }
150
+ seen.add(v)
151
+ if (Array.isArray(v)) {
152
+ v.forEach((x, i) => walk(x, `${path}[${i}]`))
153
+ } else if (isPlainObject(v)) {
154
+ for (const k of Object.keys(v)) walk(v[k], `${path}.${k}`)
155
+ } else {
156
+ violations.push(`${path}: 活对象(原型 ${String(Object.getPrototypeOf(v)?.constructor?.name)})`)
157
+ }
158
+ seen.delete(v)
159
+ }
160
+ }
161
+
162
+ /** 全序比较器:先按 `key` 升序,键相等时按 `tiebreak` 升序 —— 保证排序结果与输入顺序无关。 */export function compareBy(key, tiebreak) {
163
+ return (a, b) => {
164
+ const ka = key(a)
165
+ const kb = key(b)
166
+ if (ka < kb) return -1
167
+ if (ka > kb) return 1
168
+ const ta = tiebreak(a)
169
+ const tb = tiebreak(b)
170
+ if (ta < tb) return -1
171
+ if (ta > tb) return 1
172
+ return 0
173
+ }
174
+ }
175
+
176
+ /** 空数组/缺省都算"未声明";非数组抛错(形状错误不静默当空)。 */
177
+ export function optionalArray(v, name, code) {
178
+ if (v === undefined || v === null) return []
179
+ return assertArray(v, name, code)
180
+ }
181
+
182
+ /**
183
+ * 深拷贝并**同时**证明可 JSON 化 —— 产物里任何"活对象/函数/非有限数/环"都会在此抛错。
184
+ *
185
+ * 为什么用"拷贝"而不是"检查后原样透传":透传会把调用方的对象**引用**放进产物,
186
+ * 调用方之后改它就会改历史证据(产物必须自持)。canonicalJson 既是校验器也是序列化器。
187
+ */
188
+ export function jsonClone(value, name = 'value') {
189
+ try {
190
+ return JSON.parse(canonicalJson(value))
191
+ } catch (err) {
192
+ if (err instanceof RsiLoopError) {
193
+ throw new RsiLoopError(err.code, `${name} 不可安全 JSON 化:${err.message}`)
194
+ }
195
+ throw err
196
+ }
197
+ }
198
+
@@ -0,0 +1,20 @@
1
+ /**
2
+ * version.mjs —— 版本号的唯一定义处(单独成文件是为了避免能力实现与契约面互相 import 成环)。
3
+ *
4
+ * 两个版本号语义不同,**不要合并**:
5
+ * - `CONTRACT_VERSION`:本库**导出面**的版本(五个能力的函数签名与错误码 v0 冻结面)。major 变更须暂停报告。
6
+ * - `CAPABILITY_VERSION`:五个能力**语义**的版本(判据/失败模式清单的版本)。它进每一份产物,
7
+ * 使第三方能回答"这个结论是哪一版能力产出的"。任何能力语义变更都升它。
8
+ */
9
+
10
+ /** 导出面版本(与兄弟库同名的 CONTRACT_VERSION 惯例一致)。 */
11
+ export const CONTRACT_VERSION = '0.1.0'
12
+
13
+ /** 能力语义版本(随每份产物返回)。
14
+ *
15
+ * **2.0.0(major,用户授权)**:`directionSelection` 的 `forbid-irreversible` 约束
16
+ * **改为把 `inPlace === true` 也算作不可逆**(与 `riskAssessment` 的自动注入判据对齐)。
17
+ * 1.x 语义下"声明了禁止不可逆、却选中就地变更手段"是**可能的**(已实测复现);
18
+ * 旧语义经 `options.legacyForbidIrreversible=true` **完整保留**,供对照与回退。
19
+ */
20
+ export const CAPABILITY_VERSION = '2.0.0'