@sema-agent/settings-schema 2.1.0 → 3.0.1

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 (203) hide show
  1. package/CHANGELOG.md +253 -1
  2. package/README.md +45 -10
  3. package/conformance/VERSION +1 -0
  4. package/conformance/baseline.json +55 -0
  5. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +105 -0
  6. package/conformance/cases/c01-entry-salvage-then-typo/input.json +22 -0
  7. package/conformance/cases/c01-entry-salvage-then-typo/meta.json +10 -0
  8. package/conformance/cases/c02-hosts-clamp-stacked/expected.json +96 -0
  9. package/conformance/cases/c02-hosts-clamp-stacked/input.json +21 -0
  10. package/conformance/cases/c02-hosts-clamp-stacked/meta.json +10 -0
  11. package/conformance/cases/c03-literal-dotted-top-key/expected.json +112 -0
  12. package/conformance/cases/c03-literal-dotted-top-key/input.json +26 -0
  13. package/conformance/cases/c03-literal-dotted-top-key/meta.json +5 -0
  14. package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +77 -0
  15. package/conformance/cases/c04-all-bad-collection-not-salvaged/input.json +18 -0
  16. package/conformance/cases/c04-all-bad-collection-not-salvaged/meta.json +5 -0
  17. package/conformance/cases/c05-gate-domain-bad-entry-throws/expected.json +16 -0
  18. package/conformance/cases/c05-gate-domain-bad-entry-throws/input.json +18 -0
  19. package/conformance/cases/c05-gate-domain-bad-entry-throws/meta.json +5 -0
  20. package/conformance/cases/c06-legacy-governance-lift/expected.json +99 -0
  21. package/conformance/cases/c06-legacy-governance-lift/input.json +20 -0
  22. package/conformance/cases/c06-legacy-governance-lift/meta.json +9 -0
  23. package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +72 -0
  24. package/conformance/cases/c07-hooks-is-not-a-config-domain/input.json +43 -0
  25. package/conformance/cases/c07-hooks-is-not-a-config-domain/meta.json +6 -0
  26. package/conformance/cases/c08-limits-unknown-carried-64/expected.json +336 -0
  27. package/conformance/cases/c08-limits-unknown-carried-64/input.json +74 -0
  28. package/conformance/cases/c08-limits-unknown-carried-64/meta.json +5 -0
  29. package/conformance/cases/c09-limits-unknown-carried-65/expected.json +338 -0
  30. package/conformance/cases/c09-limits-unknown-carried-65/input.json +75 -0
  31. package/conformance/cases/c09-limits-unknown-carried-65/meta.json +7 -0
  32. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +106 -0
  33. package/conformance/cases/c10-record-key-secret-redacted-twice/input.json +29 -0
  34. package/conformance/cases/c10-record-key-secret-redacted-twice/meta.json +13 -0
  35. package/conformance/cases/c11-unicode-unknown-keys/expected.json +88 -0
  36. package/conformance/cases/c11-unicode-unknown-keys/input.json +13 -0
  37. package/conformance/cases/c11-unicode-unknown-keys/meta.json +5 -0
  38. package/conformance/cases/c12-prototype-name-top-keys/expected.json +72 -0
  39. package/conformance/cases/c12-prototype-name-top-keys/input.json +20 -0
  40. package/conformance/cases/c12-prototype-name-top-keys/meta.json +11 -0
  41. package/conformance/cases/c13-models-field-null/expected.json +77 -0
  42. package/conformance/cases/c13-models-field-null/input.json +11 -0
  43. package/conformance/cases/c13-models-field-null/meta.json +5 -0
  44. package/conformance/cases/c14-models-field-empty-array/expected.json +72 -0
  45. package/conformance/cases/c14-models-field-empty-array/input.json +11 -0
  46. package/conformance/cases/c14-models-field-empty-array/meta.json +5 -0
  47. package/conformance/cases/c15-models-field-number/expected.json +77 -0
  48. package/conformance/cases/c15-models-field-number/input.json +11 -0
  49. package/conformance/cases/c15-models-field-number/meta.json +5 -0
  50. package/conformance/cases/c16-deep-nesting-205/expected.json +83 -0
  51. package/conformance/cases/c16-deep-nesting-205/input.json +421 -0
  52. package/conformance/cases/c16-deep-nesting-205/meta.json +5 -0
  53. package/conformance/cases/c17-empty-domain-file/expected.json +73 -0
  54. package/conformance/cases/c17-empty-domain-file/input.json +10 -0
  55. package/conformance/cases/c17-empty-domain-file/meta.json +5 -0
  56. package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +74 -0
  57. package/conformance/cases/c18-wire-envelope-not-an-object/input.json +8 -0
  58. package/conformance/cases/c18-wire-envelope-not-an-object/meta.json +5 -0
  59. package/conformance/cases/c19-wire-envelope-no-version/expected.json +74 -0
  60. package/conformance/cases/c19-wire-envelope-no-version/input.json +13 -0
  61. package/conformance/cases/c19-wire-envelope-no-version/meta.json +5 -0
  62. package/conformance/cases/c20-governance-absent-lifts/expected.json +84 -0
  63. package/conformance/cases/c20-governance-absent-lifts/input.json +11 -0
  64. package/conformance/cases/c20-governance-absent-lifts/meta.json +9 -0
  65. package/conformance/cases/c21-governance-empty-object-wins/expected.json +72 -0
  66. package/conformance/cases/c21-governance-empty-object-wins/input.json +12 -0
  67. package/conformance/cases/c21-governance-empty-object-wins/meta.json +5 -0
  68. package/conformance/cases/c22-governance-null-throws/expected.json +16 -0
  69. package/conformance/cases/c22-governance-null-throws/input.json +9 -0
  70. package/conformance/cases/c22-governance-null-throws/meta.json +5 -0
  71. package/conformance/cases/c23-mixed-validity-models/expected.json +106 -0
  72. package/conformance/cases/c23-mixed-validity-models/input.json +27 -0
  73. package/conformance/cases/c23-mixed-validity-models/meta.json +9 -0
  74. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +100 -0
  75. package/conformance/cases/c24-valid-entries-with-unknown-keys/input.json +25 -0
  76. package/conformance/cases/c24-valid-entries-with-unknown-keys/meta.json +5 -0
  77. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +89 -0
  78. package/conformance/cases/c25-published-roundtrip-zero-warnings/input.json +24 -0
  79. package/conformance/cases/c25-published-roundtrip-zero-warnings/meta.json +5 -0
  80. package/conformance/cases/c26-hosts-array-1024/expected.json +7241 -0
  81. package/conformance/cases/c26-hosts-array-1024/input.json +3084 -0
  82. package/conformance/cases/c26-hosts-array-1024/meta.json +5 -0
  83. package/conformance/cases/c27-hosts-array-1025/expected.json +7248 -0
  84. package/conformance/cases/c27-hosts-array-1025/input.json +3087 -0
  85. package/conformance/cases/c27-hosts-array-1025/meta.json +5 -0
  86. package/conformance/cases/c28-large-document-over-1mb/expected.json +82 -0
  87. package/conformance/cases/c28-large-document-over-1mb/input.json +11 -0
  88. package/conformance/cases/c28-large-document-over-1mb/meta.json +5 -0
  89. package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +77 -0
  90. package/conformance/cases/c29-salvage-then-domain-refine-fails/input.json +27 -0
  91. package/conformance/cases/c29-salvage-then-domain-refine-fails/meta.json +5 -0
  92. package/conformance/cases/c30-version-triple-a-first/expected.json +85 -0
  93. package/conformance/cases/c30-version-triple-a-first/input.json +18 -0
  94. package/conformance/cases/c30-version-triple-a-first/meta.json +5 -0
  95. package/conformance/cases/c31-version-triple-b-changed/expected.json +85 -0
  96. package/conformance/cases/c31-version-triple-b-changed/input.json +18 -0
  97. package/conformance/cases/c31-version-triple-b-changed/meta.json +5 -0
  98. package/conformance/cases/c32-version-triple-a-restored/expected.json +85 -0
  99. package/conformance/cases/c32-version-triple-a-restored/input.json +18 -0
  100. package/conformance/cases/c32-version-triple-a-restored/meta.json +5 -0
  101. package/conformance/cases/c33-limits-nested-carried-key/expected.json +91 -0
  102. package/conformance/cases/c33-limits-nested-carried-key/input.json +14 -0
  103. package/conformance/cases/c33-limits-nested-carried-key/meta.json +5 -0
  104. package/conformance/cases/c34-skills-content-vs-manifest/expected.json +160 -0
  105. package/conformance/cases/c34-skills-content-vs-manifest/input.json +18 -0
  106. package/conformance/cases/c34-skills-content-vs-manifest/meta.json +5 -0
  107. package/conformance/cases/c35-portable-domain-file-is-null/expected.json +76 -0
  108. package/conformance/cases/c35-portable-domain-file-is-null/input.json +9 -0
  109. package/conformance/cases/c35-portable-domain-file-is-null/meta.json +5 -0
  110. package/conformance/cases/c36-skills-entry-without-content/expected.json +144 -0
  111. package/conformance/cases/c36-skills-entry-without-content/input.json +16 -0
  112. package/conformance/cases/c36-skills-entry-without-content/meta.json +5 -0
  113. package/conformance/cases/c37-skills-field-null/expected.json +134 -0
  114. package/conformance/cases/c37-skills-field-null/input.json +9 -0
  115. package/conformance/cases/c37-skills-field-null/meta.json +5 -0
  116. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +72 -0
  117. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/input.json +11 -0
  118. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/meta.json +5 -0
  119. package/conformance/cases/c39-hidden-file-in-config-d/expected.json +72 -0
  120. package/conformance/cases/c39-hidden-file-in-config-d/input.json +11 -0
  121. package/conformance/cases/c39-hidden-file-in-config-d/meta.json +5 -0
  122. package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +112 -0
  123. package/conformance/cases/cc01-mcp-toolfaces-non-object/input.json +39 -0
  124. package/conformance/cases/cc01-mcp-toolfaces-non-object/meta.json +8 -0
  125. package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +85 -0
  126. package/conformance/cases/cc02-mcp-source-key-is-transported/input.json +20 -0
  127. package/conformance/cases/cc02-mcp-source-key-is-transported/meta.json +8 -0
  128. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +89 -0
  129. package/conformance/cases/cc03-model-compat-unknown-subkey/input.json +22 -0
  130. package/conformance/cases/cc03-model-compat-unknown-subkey/meta.json +8 -0
  131. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +102 -0
  132. package/conformance/cases/cli01-model-name-nfc-vs-nfd/input.json +30 -0
  133. package/conformance/cases/cli01-model-name-nfc-vs-nfd/meta.json +9 -0
  134. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +115 -0
  135. package/conformance/cases/cli02-control-and-zero-width-chars/input.json +42 -0
  136. package/conformance/cases/cli02-control-and-zero-width-chars/meta.json +9 -0
  137. package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +92 -0
  138. package/conformance/cases/cli03-credential-pasted-into-unknown-key/input.json +16 -0
  139. package/conformance/cases/cli03-credential-pasted-into-unknown-key/meta.json +9 -0
  140. package/conformance/cases/cli04-env-value-with-newline/expected.json +93 -0
  141. package/conformance/cases/cli04-env-value-with-newline/input.json +23 -0
  142. package/conformance/cases/cli04-env-value-with-newline/meta.json +5 -0
  143. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +109 -0
  144. package/conformance/cases/cli05-long-and-prefix-colliding-names/input.json +30 -0
  145. package/conformance/cases/cli05-long-and-prefix-colliding-names/meta.json +5 -0
  146. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +93 -0
  147. package/conformance/cases/t01-absent-domain-key-not-null/input.json +29 -0
  148. package/conformance/cases/t01-absent-domain-key-not-null/meta.json +6 -0
  149. package/conformance/cases/t02-entry-salvage-vs-whole-domain-refusal/expected.json +16 -0
  150. package/conformance/cases/t02-entry-salvage-vs-whole-domain-refusal/input.json +32 -0
  151. package/conformance/cases/t02-entry-salvage-vs-whole-domain-refusal/meta.json +6 -0
  152. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +107 -0
  153. package/conformance/cases/t03-prototype-name-key-not-swallowed/input.json +25 -0
  154. package/conformance/cases/t03-prototype-name-key-not-swallowed/meta.json +6 -0
  155. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +105 -0
  156. package/conformance/cases/t04-secret-literal-never-in-warning-value/input.json +25 -0
  157. package/conformance/cases/t04-secret-literal-never-in-warning-value/meta.json +14 -0
  158. package/conformance/cases/z01-every-domain-empty-object/expected.json +72 -0
  159. package/conformance/cases/z01-every-domain-empty-object/input.json +25 -0
  160. package/conformance/cases/z01-every-domain-empty-object/meta.json +5 -0
  161. package/conformance/cases/z02-every-domain-unknown-key/expected.json +228 -0
  162. package/conformance/cases/z02-every-domain-unknown-key/input.json +59 -0
  163. package/conformance/cases/z02-every-domain-unknown-key/meta.json +5 -0
  164. package/conformance/historical/settings-schema@1.0.0.json +1080 -0
  165. package/conformance/historical/settings-schema@1.10.0.json +1080 -0
  166. package/conformance/historical/settings-schema@1.5.0.json +1080 -0
  167. package/conformance/historical/settings-schema@2.0.0.json +1080 -0
  168. package/conformance/historical/settings-schema@2.1.0.json +1080 -0
  169. package/dist/api/auth.d.ts +42 -1
  170. package/dist/api/auth.js +71 -4
  171. package/dist/api/scopes.d.ts +9 -1
  172. package/dist/api/scopes.js +8 -2
  173. package/dist/config-fns.d.ts +60 -28
  174. package/dist/config-fns.js +94 -77
  175. package/dist/conformance/canonical.d.ts +36 -0
  176. package/dist/conformance/canonical.js +114 -0
  177. package/dist/conformance/contract-adapter.d.ts +20 -0
  178. package/dist/conformance/contract-adapter.js +74 -0
  179. package/dist/conformance/corpus.d.ts +73 -0
  180. package/dist/conformance/corpus.js +99 -0
  181. package/dist/conformance/derive.d.ts +48 -0
  182. package/dist/conformance/derive.js +136 -0
  183. package/dist/conformance/index.d.ts +22 -0
  184. package/dist/conformance/index.js +22 -0
  185. package/dist/conformance/run.d.ts +27 -0
  186. package/dist/conformance/run.js +424 -0
  187. package/dist/conformance/types.d.ts +288 -0
  188. package/dist/conformance/types.js +4 -0
  189. package/dist/conformance/warnings.d.ts +88 -0
  190. package/dist/conformance/warnings.js +144 -0
  191. package/dist/cross-domain.d.ts +1 -1
  192. package/dist/cross-domain.js +1 -1
  193. package/dist/file-store.d.ts +7 -0
  194. package/dist/file-store.js +6 -2
  195. package/dist/hooks.d.ts +2 -2
  196. package/dist/hooks.js +2 -2
  197. package/dist/scheduler-store.d.ts +1 -1
  198. package/dist/scheduler-store.js +1 -1
  199. package/dist/types.d.ts +2 -2
  200. package/dist/types.js +3 -3
  201. package/package.json +7 -5
  202. package/dist/api/wire.d.ts +0 -60
  203. package/dist/api/wire.js +0 -89
@@ -123,7 +123,7 @@ export function parseDomain(domain, raw) {
123
123
  return r.data;
124
124
  throw sanitizeZodError(r.error); // recursive redaction (message/received/unionErrors) — no secret echo. R13/R14.
125
125
  }
126
- // ── 逐条容错律(1.11.0,S-144 按类修)───────────────────────────────────────────────────────────────
126
+ // ── 逐条容错律(2.0.0,S-144 按类修)───────────────────────────────────────────────────────────────
127
127
  //
128
128
  // 病:域解析整域 all-or-nothing —— 13 个模型里坏一条,整张目录退成 schema 默认 = 一条都不剩。缓解一直
129
129
  // 是**逐键**打补丁(把新键改成 `z.unknown()` 让它别拒),那是给每个新键加特判,规则只会越来越多;
@@ -157,6 +157,22 @@ function unwrapSchema(schema) {
157
157
  }
158
158
  return cur;
159
159
  }
160
+ /** 一个**逐字出门**的字符串段的样子 —— 一条判据,不是一串特例:**逐字写出去之后读回来只能是它自己**。
161
+ * 所以四类字符串段必须改走引号形:空串、含 `.`(段分隔符)、含 `"`(引号形自身)或控制字符的,以及
162
+ * **文本恰好是某个数字段会出成的样子**(规范十进制)—— 一个字面叫 `"1"` 的对象键若逐字出门,就与下标
163
+ * `1` 同形,而「这一段是下标还是键名」正是本版改铸要带回来的那个位。(`"01"` 不是任何数字段的文本,
164
+ * 照旧逐字。) */
165
+ const PLAIN_PATH_SEGMENT = /^(?!(?:0|[1-9]\d*)$)[^."\u0000-\u001f]+$/;
166
+ /** 一条 {@link ConfigPath} 的出门形(日志 / UI / 人读的告警文本)—— 本包**唯一**的 stringify 点,
167
+ * 消费方零复述。段间 `.`;数字段十进制;字符串段{@link PLAIN_PATH_SEGMENT 能逐字出门}就逐字,否则按
168
+ * JSON 字符串字面量加引号(`models.1."a.b"` / `models."1".k`)—— 不加引号的话,含点的键段会与数组路径
169
+ * 出成同一个串、字面叫 `"1"` 的键会与下标 `1` 同形,那正是 S-323c 要消掉的二义。
170
+ *
171
+ * **刻意不提供 parse 反函数**:2.1.0 起没有任何一处把坐标读回来(回写走 `keptIndex` 那本账),给它
172
+ * 一个反函数就是给下一个人一条「在串上做事」的路。 */
173
+ export function formatConfigPath(p) {
174
+ return p.map((seg) => (typeof seg === "number" ? String(seg) : PLAIN_PATH_SEGMENT.test(seg) ? seg : JSON.stringify(seg))).join(".");
175
+ }
160
176
  /** 一次「什么都没丢」的抢救的位移表(冻结的共享空表 —— {@link rebaseWarningPaths} 对它恒等)。 */
161
177
  const NO_ENTRIES_DROPPED = Object.freeze({});
162
178
  /** 一条条目的 parse —— 坏就返回它的(脱敏)错误。恶意 getter/Proxy 在遍历中抛错时**绝不**外泄其文案
@@ -234,7 +250,7 @@ function salvageEntriesUnsafe(schema, raw) {
234
250
  keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
235
251
  return true;
236
252
  }
237
- bad.push({ path: `${field}.${i}`, error });
253
+ bad.push({ path: [field, i], error });
238
254
  return false;
239
255
  });
240
256
  // 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
@@ -254,7 +270,7 @@ function salvageEntriesUnsafe(schema, raw) {
254
270
  if (error === undefined)
255
271
  kept[key] = entry;
256
272
  else
257
- bad.push({ path: `${field}.${redactSecrets(key)}`, error }); // 键是用户写的,可能就是一条粘错位置的凭据
273
+ bad.push({ path: [field, redactSecrets(key)], error }); // 键是用户写的,可能就是一条粘错位置的凭据
258
274
  }
259
275
  if (bad.length > 0 && Object.keys(kept).length > 0) {
260
276
  dropped.push(...bad);
@@ -290,7 +306,7 @@ export function statusRowsToPrune(rows, opts) {
290
306
  * store upgrades with zero data migration and zero gate loss. An explicitly-set governance domain WINS
291
307
  * (its absence-of-a-key then means "center not managing", never "fall back to legacy runtime").
292
308
  *
293
- * SUNSET (1.11.0): removal is a **persistent-data** break — a store still carrying the 0.5 shape would lose
309
+ * SUNSET (2.0.0): removal is a **persistent-data** break — a store still carrying the 0.5 shape would lose
294
310
  * its guardrails silently, so this arm cannot simply be deleted at a minor. It goes at the next MAJOR, with
295
311
  * the standard persistent-data treatment (a loud refusal naming the domain + a one-time migration on the
296
312
  * writer side), and the closing condition is observable rather than a promise: `buildEffective` now emits
@@ -306,8 +322,8 @@ export function legacyGovernanceFromRuntime(rawRuntime) {
306
322
  return Object.keys(lifted).length ? lifted : undefined;
307
323
  }
308
324
  /**
309
- * 一条告警里的**点号路径**从「抢救压缩后的坐标」回写成「原文档的坐标」—— 纯函数,{@link EntryKeptIndex}
310
- * 是它唯一的坐标账。
325
+ * 一条告警里的**段坐标**({@link ConfigPath})从「抢救压缩后的坐标」回写成「原文档的坐标」—— 纯函数,
326
+ * {@link EntryKeptIndex} 是它唯一的坐标账。
311
327
  *
312
328
  * 成案(S-323,消费方真案):整域 parse 失败后 {@link salvageEntries} 会把坏条目从数组里剔掉,之后的
313
329
  * 诊断({@link droppedKeys} / {@link carriedUnknownKeys})是拿**压缩后**的文档做键集对比出来的,它报的
@@ -334,14 +350,14 @@ export function rebaseWarningPaths(w, keptIndex) {
334
350
  case "unknown-keys-dropped":
335
351
  case "unknown-keys-carried":
336
352
  case "legacy-governance-lifted": {
337
- const keys = rebasePaths(w.keys, keptIndex, "key");
353
+ const keys = rebasePaths(w.keys, keptIndex);
338
354
  return keys === w.keys ? w : { ...w, keys };
339
355
  }
340
356
  case "entry-dropped": {
341
- const path = rebasePath(w.path, keptIndex, "entry");
357
+ const path = rebasePath(w.path, keptIndex);
342
358
  return path === w.path ? w : { ...w, path };
343
359
  }
344
- // 以下三臂不带任何点号路径:`hosts-grandfathered` 点名的是主机**名**,另两条点名的是错误/文件名。
360
+ // 以下三臂不带任何坐标:`hosts-grandfathered` 点名的是主机**名**,另两条点名的是错误/文件名。
345
361
  case "hosts-grandfathered":
346
362
  case "domain-defaulted":
347
363
  case "unread-config-file":
@@ -355,11 +371,11 @@ export function rebaseWarningPaths(w, keptIndex) {
355
371
  }
356
372
  }
357
373
  }
358
- /** 一组点号路径的回写。全部原样 ⇒ 返回**原数组引用**(调用方据此判「什么都没变」)。 */
359
- function rebasePaths(keys, keptIndex, grammar) {
374
+ /** 一组坐标的回写。全部原样 ⇒ 返回**原数组引用**(调用方据此判「什么都没变」)。 */
375
+ function rebasePaths(keys, keptIndex) {
360
376
  let changed = false;
361
377
  const out = keys.map((k) => {
362
- const rebased = rebasePath(k, keptIndex, grammar);
378
+ const rebased = rebasePath(k, keptIndex);
363
379
  if (rebased !== k)
364
380
  changed = true;
365
381
  return rebased;
@@ -367,44 +383,30 @@ function rebasePaths(keys, keptIndex, grammar) {
367
383
  return changed ? out : keys;
368
384
  }
369
385
  /**
370
- * `field.N.rest` → `field.<原 N>.rest`。**按段比对**,不在拼好的串上做前缀匹配:`models` 的位移表绝不许
371
- * 动到 `modelsX.1.k`(同名前缀是两个字段)。
386
+ * `[field, N, ...rest]` → `[field, 原 N, ...rest]`。**一条律**(S-323c,3.0.0):`p[0]` 是字符串且在位移表里
387
+ * (= 那个数组字段真被压缩过)∧ `p[1]` 是**数字段** ⇔ 它是一条数组路径。不满足 ⇒ 原样。
372
388
  *
373
- * 回写的**定义域 = `grammar` 那一支真正铸得出来的数组路径**,不是「长得像下标就算」(两支文法的差别与
374
- * 合并的代价见 {@link PathGrammar})。三道门,缺一不回写:
375
- * · `field` 必须在位移表里(= 那个字段真被压缩过);
376
- * · 段数合文法:`"entry"` 支的数组条目**恰好两段**,`"key"` 支**至少三段**;
377
- * · 下标段必须是**规范十进制**(`${i}` 的像:无前导零、无符号、无空白)且落在表长内。
378
- * 不满足 ⇒ 原样返回。
389
+ * 2.1.0 在点号串上需要三道门(字段在表里 / 段数合它那一支文法 / 下标段是规范十进制),因为串把「这一段
390
+ * 是下标还是键名」这个位丢了,只能靠文法猜回来;段形把那个位带在类型里,三道门收成一道 —— 判它是正确
391
+ * 抽象的第一判据就是**规则数变少**(`PathGrammar`、段数门、规范十进制正则三件随之删除)。
379
392
  *
380
- * Record 形的条目路径(`${field}.${key}`)天然不受影响:Record 字段从不进位移表(条目按键寻址,丢一条
381
- * 不动其余键的坐标),`hasOwnProperty` 那道门就把它挡在外面了。
393
+ * 字面含点的顶层键(`["models.1.k"]`)天然不受影响:它只有一段,`p[1]` 缺席。Record 形的条目坐标
394
+ * (`[field, key]`)同样:Record 字段从不进位移表(条目按键寻址,丢一条不动其余键的坐标),而它的第二段
395
+ * 是字符串键、不是下标 —— 两道判据各自都挡得住。
382
396
  *
383
- * 如实登记的残余歧义(S-323c,本版**不消**):点号路径是有损编码 —— 键名里本来就允许含 `.`
384
- * (见 {@link isReservedExtensionKey} 的分段匹配注),所以一个**字面叫 `models.1.k` 的顶层键**与
385
- * `models[1]` 里的未知键 `k` 仍然编码成同一个串,无从分辨(裸 `models.1` 那一半已被上面的段数门挡住)。
386
- * 那是编码本身的歧义(回写之前就在:两版都指不到那个字面键,只是数字不同);要消掉它得让扫描与抢救
387
- * 改铸**结构化路径段**、出门时才 stringify —— 那是 {@link EffectiveReadWarning} 的 BREAKING,归下一个 major。
397
+ * **恒等纪律**:没有任何一段真的变了(表长外、或原下标与压缩后下标相同)⇒ 返回**原引用**,不复制。
388
398
  */
389
- function rebasePath(key, keptIndex, grammar) {
390
- const firstDot = key.indexOf(".");
391
- if (firstDot <= 0)
392
- return key; // 顶层键,没有下标可言
393
- const field = key.slice(0, firstDot);
394
- if (!Object.prototype.hasOwnProperty.call(keptIndex, field))
395
- return key;
396
- const rest = key.slice(firstDot + 1);
397
- const secondDot = rest.indexOf(".");
398
- const bare = secondDot < 0;
399
- if (bare !== (grammar === "entry"))
400
- return key; // 段数不合本支文法 ⇒ 它不是这一支铸出来的数组路径
401
- const indexText = bare ? rest : rest.slice(0, secondDot);
402
- if (!/^(?:0|[1-9]\d*)$/.test(indexText))
403
- return key; // 规范十进制才是 `${i}` 的像
404
- const original = keptIndex[field][Number(indexText)];
405
- if (original === undefined)
406
- return key;
407
- return bare ? `${field}.${original}` : `${field}.${original}.${rest.slice(secondDot + 1)}`;
399
+ function rebasePath(p, keptIndex) {
400
+ const field = p[0];
401
+ if (typeof field !== "string" || !Object.prototype.hasOwnProperty.call(keptIndex, field))
402
+ return p;
403
+ const index = typeof p[1] === "number" ? p[1] : undefined;
404
+ if (index === undefined)
405
+ return p;
406
+ const original = keptIndex[field][index];
407
+ if (original === undefined || original === index)
408
+ return p; // 表长外 ⇒ 原样(与 2.1.0「越界原样」同律)
409
+ return [field, original, ...p.slice(2)];
408
410
  }
409
411
  /** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
410
412
  const DROP_SCAN_MAX_DEPTH = 8;
@@ -418,22 +420,32 @@ const DROP_SCAN_MAX_KEYS = 64;
418
420
  * 满额特判,四层各被下一轮复审打穿)。意图只能在**声明处**说清:写者在这里登记自己的命名空间,之后
419
421
  * 所有未被登记的未知键都是拼错,消费方一条规则:未知键 = 错误,零特例。
420
422
  *
421
- * 登记形(分段):`at` = 父路径正则(点号形含尾点,顶层 `""`,首尾锚定,test 有门)+ `key` = 键名前缀正则;`owner` = 写者;
423
+ * 登记形(分段):`at` = 父路径的**段模式**({@link ConfigPath} 的形加一个通配哨兵 {@link ANY_INDEX} =
424
+ * 任一数组下标,顶层 = `[]`;逐段全等,天然闭合——没有正则可以锚歪,test 有门)+ `key` = 键名前缀正则;
425
+ * `owner` = 写者;
422
426
  * `since` = 契约出处。新写者/新层级来这里加行,并在 CHANGELOG 点名——这是 additive(minor)。
423
427
  *
424
428
  * · `models` 条目级 `sema[A-Z]…`:壳(cli)的自留字段族(`semaChannel`/`semaLabel`/`semaProbe`/`semaProbeAt`/
425
429
  * `semaEffortDefault`/`semaVision`,壳侧 models.json 实键清单 design/cli-190c:「`sema` 前缀是壳侧自留字段的既定
426
430
  * 约定」,**全条目级、顶层零装饰键**)。顶层 `sema*` 不在此列 ⇒ 仍是未知键(拼错或越界)。
427
431
  */
432
+ /** {@link EXTENSION_NAMESPACES} 段模式里的**通配段**:匹配任一数组下标。
433
+ *
434
+ * 刻意是 `unique symbol` 而不是一个约定字符串(如 `"#"`):文档键只可能是 `string | number`
435
+ * ({@link ConfigPath} 的值域),所以哨兵**不可能**与任何文档键相等 —— 通配与字面「一个叫 `#` 的键」
436
+ * 这两件事零二义,而且字面 `"#"` 仍然是一个可登记的普通段。类型上也不塌:`readonly (string | "#")[]`
437
+ * 会被 TS 收成 `readonly string[]`(写错通配不编译红),`readonly (string | typeof ANY_INDEX)[]` 不会。 */
438
+ export const ANY_INDEX = Symbol("settings-schema/any-index");
428
439
  export const EXTENSION_NAMESPACES = {
429
- models: [{ at: /^models\.\d+\.$/, key: /^sema[A-Z]/, owner: "cli", since: "shell key census design/cli-190c (2026-08-26)" }],
440
+ models: [{ at: ["models", ANY_INDEX], key: /^sema[A-Z]/, owner: "cli", since: "shell key census design/cli-190c (2026-08-26)" }],
430
441
  };
431
- /** 该键是否落在 {@link EXTENSION_NAMESPACES} 登记的扩展命名空间里(⇒ 不是未知键)。**分段匹配**:`at` 对父路径(点号形,含尾点;
432
- * 顶层 = `""`),`key` 对键名本身——不在拼好的点号路径上猜(键名里可以含 `.`,`semaX.foo` 是一个键不是两层),前缀契约就是
433
- * 前缀契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
434
- export function isReservedExtensionKey(domain, parentPath, key) {
442
+ /** 该键是否落在 {@link EXTENSION_NAMESPACES} 登记的扩展命名空间里(⇒ 不是未知键)。**分段匹配**:`at` 对
443
+ * 父路径的段(长度相等 ∧ 逐段——{@link ANY_INDEX} 匹配任一**数字**段,字符串段逐字相等;顶层 = `[]`),`key` 对键名
444
+ * 本身——键名里可以含 `.`(`semaX.foo` 是一个键不是两层),段形让这件事不必再靠文法猜;前缀契约就是前缀
445
+ * 契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
446
+ export function isReservedExtensionKey(domain, parent, key) {
435
447
  const rules = EXTENSION_NAMESPACES[domain];
436
- return rules !== undefined && rules.some((r) => r.at.test(parentPath) && r.key.test(key));
448
+ return rules !== undefined && rules.some((r) => r.at.length === parent.length && r.at.every((seg, i) => (seg === ANY_INDEX ? typeof parent[i] === "number" : seg === parent[i])) && r.key.test(key));
437
449
  }
438
450
  /** 纯 JSON 对象(不是数组、不是类实例/带原型的东西)——只对这种做键集对比,别的形状交给 parse 判。 */
439
451
  function isPlainJsonObject(v) {
@@ -450,7 +462,7 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
450
462
  if (raw.length !== parsed.length)
451
463
  return;
452
464
  for (let i = 0; i < raw.length; i++)
453
- collectDropped(domain, raw[i], parsed[i], `${prefix}${i}.`, depth + 1, [], acc);
465
+ collectDropped(domain, raw[i], parsed[i], [...prefix, i], depth + 1, [], acc);
454
466
  return;
455
467
  }
456
468
  if (!isPlainJsonObject(raw) || !isPlainJsonObject(parsed))
@@ -463,16 +475,16 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
463
475
  continue; // 扩展命名空间:按契约放的键,不是未知键
464
476
  acc.total += 1;
465
477
  if (acc.keys.length < DROP_SCAN_MAX_KEYS)
466
- acc.keys.push(prefix + k);
478
+ acc.keys.push([...prefix, k]);
467
479
  continue;
468
480
  }
469
- collectDropped(domain, raw[k], parsed[k], `${prefix}${k}.`, depth + 1, spared, acc);
481
+ collectDropped(domain, raw[k], parsed[k], [...prefix, k], depth + 1, spared, acc);
470
482
  }
471
483
  }
472
484
  function scanDropped(domain, raw, parsed) {
473
485
  const acc = { keys: [], total: 0 };
474
486
  try {
475
- collectDropped(domain, raw, parsed, "", 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], acc);
487
+ collectDropped(domain, raw, parsed, [], 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], acc);
476
488
  }
477
489
  catch {
478
490
  return { keys: [], total: 0 }; // 反射/访问器抛错:告警是尽力而为的审计线,绝不能把解析拖下水,更不能回显其错误文案
@@ -480,7 +492,7 @@ function scanDropped(domain, raw, parsed) {
480
492
  return acc;
481
493
  }
482
494
  /**
483
- * 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),点号路径形。
495
+ * 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),段坐标形({@link ConfigPath})。
484
496
  *
485
497
  * 成案(消费方 #322 真事故):往 `runtime` 文档里加一个不属于该域的键,两条腿都会把它剥掉,
486
498
  * `domainErrors` 空、解析"成功"——配置写了、值没到、零告警。剥除行为本身是**刻意的前向兼容**
@@ -503,8 +515,8 @@ export function droppedKeys(domain, raw, parsed) {
503
515
  return scanDropped(domain, raw, parsed).keys;
504
516
  }
505
517
  /**
506
- * open-world 域(`.passthrough()`)的**已知键表**:路径 → 该层已知键(`""` = 顶层)。键集从 schema 的
507
- * `shape` **单源派生**(手抄一份必然漂),路径表本身是显式的——嵌套只在真有 open-world 子对象的地方登记。
518
+ * open-world 域(`.passthrough()`)的**已知键表**:段坐标 → 该层已知键(`[]` = 顶层)。键集从 schema 的
519
+ * `shape` **单源派生**(手抄一份必然漂),层级表本身是显式的——嵌套只在真有 open-world 子对象的地方登记。
508
520
  *
509
521
  * 为什么需要它(对抗复审 复审 round2 F2):承运解决了「值到不了」,但没解决「键名拼错了没人说」——
510
522
  * `toolResultsTtlSec`(多一个 s)会完好穿过本包,而消费方只遍历自己的键表,于是那条天花板悄悄没生效。
@@ -514,10 +526,13 @@ export function droppedKeys(domain, raw, parsed) {
514
526
  * `unknownKeys === "passthrough"` 的域 schema 都必须在本表里有行(缺行 = 该域的未知键无人点名)。
515
527
  */
516
528
  const OPEN_WORLD_KNOWN_KEYS = {
517
- limits: { "": Object.keys(LimitsConfig.shape), infraCostRates: Object.keys(InfraCostRates.shape) },
529
+ limits: [
530
+ [[], Object.keys(LimitsConfig.shape)],
531
+ [["infraCostRates"], Object.keys(InfraCostRates.shape)],
532
+ ],
518
533
  };
519
534
  /**
520
- * 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(点号路径)。非 open-world 域恒空
535
+ * 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(段坐标)。非 open-world 域恒空
521
536
  * (它们的未知键走 {@link droppedKeys} 那条更重的「被丢了」告警)。
522
537
  *
523
538
  * 边界(如实):只看 {@link OPEN_WORLD_KNOWN_KEYS} 登记了的层级 —— 未登记层级(如某个未知键自己的
@@ -529,21 +544,21 @@ export function carriedUnknownKeys(domain, parsed) {
529
544
  function scanCarried(domain, parsed) {
530
545
  const table = OPEN_WORLD_KNOWN_KEYS[domain];
531
546
  const acc = { keys: [], total: 0 };
532
- if (!table || !isPlainJsonObject(parsed))
547
+ if (table === undefined)
533
548
  return acc;
534
- for (const [path, known] of Object.entries(table)) {
535
- const node = path === "" ? parsed : parsed[path];
549
+ for (const [at, known] of table) {
550
+ // 段形下探:`[]` 落在文档根上,所以「顶层 vs 嵌套」不再是两条分支(规则少一条)。
551
+ const node = at.reduce((cur, seg) => (isPlainJsonObject(cur) ? cur[String(seg)] : undefined), parsed);
536
552
  if (!isPlainJsonObject(node))
537
553
  continue;
538
- const prefix = path === "" ? "" : `${path}.`;
539
554
  for (const k of Object.keys(node)) {
540
555
  if (known.includes(k))
541
556
  continue;
542
- if (isReservedExtensionKey(domain, prefix, k))
557
+ if (isReservedExtensionKey(domain, at, k))
543
558
  continue;
544
559
  acc.total += 1;
545
560
  if (acc.keys.length < DROP_SCAN_MAX_KEYS)
546
- acc.keys.push(prefix + k);
561
+ acc.keys.push([...at, k]);
547
562
  }
548
563
  }
549
564
  return acc;
@@ -622,7 +637,7 @@ export const DOMAIN_READ_FALLBACK = {
622
637
  * everything else (regex, types, duplicates) is left for parseDomain — this is a targeted grandfather, not a
623
638
  * lenient parser. PURE + idempotent; returns the ORIGINAL reference when nothing exceeds the bounds.
624
639
  *
625
- * SUNSET (1.11.0): same persistent-data class as the governance lift — it goes at the next MAJOR. This one was
640
+ * SUNSET (2.0.0): same persistent-data class as the governance lift — it goes at the next MAJOR. This one was
626
641
  * never silent (`hosts-grandfathered` fires whenever it clamps), so the closing condition is already
627
642
  * observable: a deployment that has not seen that warning across a full read cycle no longer needs the arm. */
628
643
  export function grandfatherHostsRead(raw) {
@@ -682,7 +697,7 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
682
697
  catch (e) {
683
698
  if (DOMAIN_READ_FALLBACK[domain] === "throw")
684
699
  throw e;
685
- // 逐条容错(1.11.0)在整域降级**之前**试一次:只丢掉真正坏掉的那几条条目,同域好兄弟活下来。
700
+ // 逐条容错(2.0.0)在整域降级**之前**试一次:只丢掉真正坏掉的那几条条目,同域好兄弟活下来。
686
701
  // 告警先缓存再决定 —— 一个域只给一个结论:救活了就逐条报 `entry-dropped`,没救活就只报
687
702
  // `domain-defaulted`(那一域的条目反正一条都不剩,再讲「丢了第 3 条」只会误导)。
688
703
  const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
@@ -713,9 +728,9 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
713
728
  let governanceSource = governanceRaw;
714
729
  if (governanceRaw === undefined) {
715
730
  const lifted = legacyGovernanceFromRuntime(getRaw("runtime"));
716
- // 兼容臂真生效时出声(1.11.0):行为一字不变,但窗要能关就得有人看得见它还开着。
731
+ // 兼容臂真生效时出声(2.0.0):行为一字不变,但窗要能关就得有人看得见它还开着。
717
732
  if (lifted !== undefined)
718
- onWarning?.({ domain: "governance", kind: "legacy-governance-lifted", keys: Object.keys(lifted) });
733
+ onWarning?.({ domain: "governance", kind: "legacy-governance-lifted", keys: Object.keys(lifted).map((k) => [k]) });
719
734
  governanceSource = lifted;
720
735
  }
721
736
  const governance = parseLoud("governance", governanceSource);
@@ -814,11 +829,13 @@ export function readEffectiveWire(raw, onWarning) {
814
829
  }
815
830
  }
816
831
  // Open-world passthrough: keys that are neither validated domains nor the envelope fields ride verbatim.
817
- const passthrough = {};
818
- for (const [k, v] of Object.entries(r)) {
819
- if (!(k in built) && k !== "skills")
820
- passthrough[k] = v;
821
- }
832
+ // `hasOwnProperty.call(built, k)`,**不是** `k in built`(S-332):`in` 会一路问到 `Object.prototype`,
833
+ // 于是 `constructor` / `toString` / `hasOwnProperty` / `__proto__` 这些**原型链上的名字**作未知顶层键时
834
+ // 被判成「已知域」而整条吞掉 —— open-world 的承诺(本包不认识的键原样送达)对它们恰好失效,而且无声。
835
+ // 与本文件另两处自有键判据(:548 `rebasePath` 查位移表、:612 `collectDropped` 比键集)同一条律,零特判。
836
+ // `Object.fromEntries`,**不是**逐键 `passthrough[k] = v`:后者对 `"__proto__"` 走的是原型 setter
837
+ // (改掉对象的原型,而不是加一个键),fromEntries 走 CreateDataProperty —— 同一条「键就是键」的律。
838
+ const passthrough = Object.fromEntries(Object.entries(r).filter(([k]) => !Object.prototype.hasOwnProperty.call(built, k) && k !== "skills"));
822
839
  // prompts:剥掉 buildEffective 恒物化的 {bindings:[]} 底座,按 wire 在场性 verbatim 挂回(#100:
823
840
  // 缺席进缺席出——center worker-lane 闸关是常态形,物化会让消费端「缺席」分支永不可达)。
824
841
  const { prompts: _promptsMaterialized, ...builtSansPrompts } = built;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 规范化与结构化比对的**单铸点**。消费方零复述 —— 一份被各自复述的规范化就是第二份真源。
3
+ *
4
+ * 两件事,刻意分开:
5
+ * · {@link canonicalize} —— 把一份 JSON 值收成**确定序**的形(对象键排序、数组保序)。它是**比对的
6
+ * 预处理**,不是比对本身。
7
+ * · {@link deepEqual} / {@link diffEffective} —— 结构化深相等与差异定位。**绝不**把值 stringify 之后比串:
8
+ * 串是有损编码(`["a.b"]` 与 `["a","b"]` 拼出来同一个串),而消掉那个二义正是 3.0.0 改铸段坐标的理由。
9
+ *
10
+ * `stableKey` 是唯一一处把值转成串的地方,它只用来给**多重集计数**当哈希键 —— 用的是 JSON 编码,
11
+ * 数组边界与段类型(`"1"` vs `1`)在编码里都还在,所以它不是那种有损的点号串。
12
+ */
13
+ /**
14
+ * 键排序、数组保序的规范形。`undefined` 值的键**被剔掉**(JSON 里没有 `undefined`,留着它会让
15
+ * 「键不在场」与「键在场但值是 undefined」在比对上分成两件事,而 wire 上它们同形)。
16
+ */
17
+ export declare function canonicalize(value: unknown): unknown;
18
+ /** 规范形的稳定串编码 —— **只**给多重集计数当键用(见本文件头注)。 */
19
+ export declare function stableKey(value: unknown): string;
20
+ /** 结构化深相等(数组保序、对象键集相等、标量 `Object.is`)。 */
21
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
22
+ export interface JsonPatchOp {
23
+ op: "add" | "remove" | "replace";
24
+ path: string;
25
+ expected?: unknown;
26
+ actual?: unknown;
27
+ }
28
+ /**
29
+ * 两份 effective 的差,按 JSON Pointer 逐处点名。**诊断输出** —— 判「相等与否」的是 {@link deepEqual},
30
+ * 本函数只负责把不等的地方说清楚。
31
+ *
32
+ * **结构不变式(承重面)**:`deepEqual(e, a) === false` ⇒ 返回的 patch **非空**。`run.ts` 的 judge 是按
33
+ * `effectivePatch.length === 0` 判 pass 的,所以空数组在那里就等于「相等」;一旦这条不变式破了,不等的
34
+ * 两份 effective 会静默判绿(3.0.0 发前补复审 ① 的成案形)。钉在 `test/conformance-comparator.test.ts`。
35
+ */
36
+ export declare function diffJson(expected: unknown, actual: unknown, at?: readonly (string | number)[]): JsonPatchOp[];
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 规范化与结构化比对的**单铸点**。消费方零复述 —— 一份被各自复述的规范化就是第二份真源。
3
+ *
4
+ * 两件事,刻意分开:
5
+ * · {@link canonicalize} —— 把一份 JSON 值收成**确定序**的形(对象键排序、数组保序)。它是**比对的
6
+ * 预处理**,不是比对本身。
7
+ * · {@link deepEqual} / {@link diffEffective} —— 结构化深相等与差异定位。**绝不**把值 stringify 之后比串:
8
+ * 串是有损编码(`["a.b"]` 与 `["a","b"]` 拼出来同一个串),而消掉那个二义正是 3.0.0 改铸段坐标的理由。
9
+ *
10
+ * `stableKey` 是唯一一处把值转成串的地方,它只用来给**多重集计数**当哈希键 —— 用的是 JSON 编码,
11
+ * 数组边界与段类型(`"1"` vs `1`)在编码里都还在,所以它不是那种有损的点号串。
12
+ */
13
+ /** 纯 JSON 对象(不是数组、不是类实例)。 */
14
+ function isPlainObject(v) {
15
+ return v !== null && typeof v === "object" && !Array.isArray(v);
16
+ }
17
+ /**
18
+ * 「这个对象**在场**的键」—— 本文件里唯一一处判键在不在 / 取键的值的地方。
19
+ *
20
+ * 两条律合成一条,别处零复述:
21
+ * · **只认自有键**(`Object.prototype.hasOwnProperty.call`):`__proto__` / `constructor` / `toString` /
22
+ * `hasOwnProperty` 这四个名字落在 `Object.prototype` 上,裸下标 `o[k]` 会在**缺席**的那一侧读到继承值
23
+ * ——`{"__proto__":{}}`(JSON.parse 得自有键)对 `{}` 于是两侧都 `!== undefined`,既不 add 也不 remove,
24
+ * 递归进 `diffJson({}, Object.prototype)` 又深相等 ⇒ **`deepEqual` 说不等而 `diffJson` 返回空** ⇒
25
+ * judge 拿空 patch 判 pass。那正是 S-332「键就是键」要防的一类丢键,门反而对它全盲。
26
+ * · **`undefined` 值 = 不在场**(JSON 里没有 `undefined`;留着它会让「键不在场」与「键在场但值 undefined」
27
+ * 在比对上分成两件事,而 wire 上它们同形)。
28
+ */
29
+ function ownValue(o, k) {
30
+ return Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined;
31
+ }
32
+ /** 在场的自有键(排序)—— 规范化、深相等、差异定位三处共用同一份「键集」定义。 */
33
+ function presentKeys(o) {
34
+ return Object.keys(o)
35
+ .filter((k) => ownValue(o, k) !== undefined)
36
+ .sort();
37
+ }
38
+ /**
39
+ * 键排序、数组保序的规范形。`undefined` 值的键**被剔掉**(JSON 里没有 `undefined`,留着它会让
40
+ * 「键不在场」与「键在场但值是 undefined」在比对上分成两件事,而 wire 上它们同形)。
41
+ */
42
+ export function canonicalize(value) {
43
+ if (Array.isArray(value))
44
+ return value.map(canonicalize);
45
+ if (isPlainObject(value)) {
46
+ // `Object.fromEntries`,**不是**逐键 `out[k] = …`:后者对 `"__proto__"` 走的是原型 setter(改掉对象的
47
+ // 原型,既不产生自有键、还污染返回值),于是规范化会把一个叫 `__proto__` 的顶层键**吃掉** —— 两边
48
+ // 同样被吃掉 = 比对器对它全盲 = 一格假绿。与 S-332 在 wire 透传处修的是同一条「键就是键」的律。
49
+ return Object.fromEntries(presentKeys(value).map((k) => [k, canonicalize(ownValue(value, k))]));
50
+ }
51
+ return value;
52
+ }
53
+ /** 规范形的稳定串编码 —— **只**给多重集计数当键用(见本文件头注)。 */
54
+ export function stableKey(value) {
55
+ return JSON.stringify(canonicalize(value));
56
+ }
57
+ /** 结构化深相等(数组保序、对象键集相等、标量 `Object.is`)。 */
58
+ export function deepEqual(a, b) {
59
+ if (Object.is(a, b))
60
+ return true;
61
+ if (Array.isArray(a) && Array.isArray(b))
62
+ return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
63
+ if (isPlainObject(a) && isPlainObject(b)) {
64
+ const ka = presentKeys(a);
65
+ const kb = presentKeys(b);
66
+ return ka.length === kb.length && ka.every((k, i) => k === kb[i]) && ka.every((k) => deepEqual(ownValue(a, k), ownValue(b, k)));
67
+ }
68
+ return false;
69
+ }
70
+ /** RFC 6901 的转义(`~` → `~0`,`/` → `~1`)。 */
71
+ const pointerSegment = (s) => (typeof s === "number" ? String(s) : s.replace(/~/g, "~0").replace(/\//g, "~1"));
72
+ /**
73
+ * 两份 effective 的差,按 JSON Pointer 逐处点名。**诊断输出** —— 判「相等与否」的是 {@link deepEqual},
74
+ * 本函数只负责把不等的地方说清楚。
75
+ *
76
+ * **结构不变式(承重面)**:`deepEqual(e, a) === false` ⇒ 返回的 patch **非空**。`run.ts` 的 judge 是按
77
+ * `effectivePatch.length === 0` 判 pass 的,所以空数组在那里就等于「相等」;一旦这条不变式破了,不等的
78
+ * 两份 effective 会静默判绿(3.0.0 发前补复审 ① 的成案形)。钉在 `test/conformance-comparator.test.ts`。
79
+ */
80
+ export function diffJson(expected, actual, at = []) {
81
+ const path = at.length === 0 ? "" : "/" + at.map(pointerSegment).join("/");
82
+ if (deepEqual(expected, actual))
83
+ return [];
84
+ if (Array.isArray(expected) && Array.isArray(actual)) {
85
+ const out = [];
86
+ for (let i = 0; i < Math.max(expected.length, actual.length); i++) {
87
+ if (i >= expected.length)
88
+ out.push({ op: "add", path: `${path}/${i}`, actual: actual[i] });
89
+ else if (i >= actual.length)
90
+ out.push({ op: "remove", path: `${path}/${i}`, expected: expected[i] });
91
+ else
92
+ out.push(...diffJson(expected[i], actual[i], [...at, i]));
93
+ }
94
+ return out;
95
+ }
96
+ if (isPlainObject(expected) && isPlainObject(actual)) {
97
+ const out = [];
98
+ const keys = [...new Set([...presentKeys(expected), ...presentKeys(actual)])].sort();
99
+ for (const k of keys) {
100
+ // 取值走 `ownValue`(不是裸下标):缺席的那一侧对原型名键会读到 `Object.prototype` 上的继承值,
101
+ // 于是 add/remove 判据失效 —— 详见 `ownValue` 的注。
102
+ const e = ownValue(expected, k);
103
+ const a = ownValue(actual, k);
104
+ if (e === undefined && a !== undefined)
105
+ out.push({ op: "add", path: `${path}/${pointerSegment(k)}`, actual: a });
106
+ else if (e !== undefined && a === undefined)
107
+ out.push({ op: "remove", path: `${path}/${pointerSegment(k)}`, expected: e });
108
+ else
109
+ out.push(...diffJson(e, a, [...at, k]));
110
+ }
111
+ return out;
112
+ }
113
+ return [{ op: "replace", path, expected, actual }];
114
+ }
@@ -0,0 +1,20 @@
1
+ import type { ConformanceAdapter, ConformanceProvenance, LegEntry, LegName } from "./types.js";
2
+ /** 一份契约模块(新旧版同形 —— 三条腿的签名自 0.19.0 起没动过)。 */
3
+ export interface ContractModule {
4
+ buildEffective(version: number, getRaw: (d: string) => unknown, updatedAt: string, onWarning?: (w: unknown) => void): unknown;
5
+ readEffectiveWire(raw: unknown, onWarning?: (w: unknown) => void): unknown;
6
+ loadLocalConfig(root: string, opts?: {
7
+ onWarning?: (w: unknown) => void;
8
+ }): Promise<{
9
+ effective: unknown;
10
+ warnings: unknown[];
11
+ }>;
12
+ }
13
+ export interface ContractAdapterOptions {
14
+ name: string;
15
+ provenance: ConformanceProvenance;
16
+ mod: ContractModule;
17
+ /** 三条腿各自的生产入口坐标(绝对路径)。 */
18
+ entries: Record<LegName, LegEntry>;
19
+ }
20
+ export declare function createContractAdapter(opts: ContractAdapterOptions): ConformanceAdapter;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 契约仓自己的三腿适配器 —— 也是 `historical` 模式拿旧版包跑时用的同一只工厂。
3
+ *
4
+ * **它不是给消费方抄的**:消费方登记的腿必须是**自己生产代码**的入口(来源律,§2d-2);把契约包的函数
5
+ * 注册成腿,门证明的就只是「包等于包」。本工厂存在的理由只有两个:①契约仓自证三腿同源(对契约仓而言
6
+ * `buildEffective` / `readEffectiveWire` / `loadLocalConfig` **就是**它的生产入口);②historical 模式要对
7
+ * 一份旧版 dist 跑同样的三腿。
8
+ *
9
+ * 拒绝分层的判据(一条,不是一串特例):**ZodError 形的错 = `validate`**(某个域没通过它的 schema);
10
+ * 其余按腿各自的默认层 —— wire 腿是 `envelope`(它在解析任何域之前先判信封),local-root 腿是 `parse`
11
+ * (文件层先判:空文件 / 非 JSON / 非对象),domains 腿本来就只有校验这一层。
12
+ */
13
+ import { materializeLocalRoot } from "./run.js";
14
+ /** ZodError 形(鸭子判,不用 `instanceof` —— historical 模式下旧版 dist 可能挂在另一份 zod 上)。 */
15
+ const isZodLike = (e) => e !== null && typeof e === "object" && Array.isArray(e.issues);
16
+ /** 内容哈希那一层拒绝的自述 —— `stableStringify` 的深度闸是**本包自己**抛的、也只有它会抛这一句;
17
+ * 适配器给自家流水线分层,认得自家那句话是合理的(消费方的适配器认它自己那几层的错)。 */
18
+ const HASH_STAGE_MARK = "stableStringify:";
19
+ const reject = (e, fallback) => {
20
+ const diagnostics = e instanceof Error ? e.message : String(e);
21
+ const stage = isZodLike(e) ? "validate" : diagnostics.startsWith(HASH_STAGE_MARK) ? "hash" : fallback;
22
+ return { outcome: "rejected", stage, diagnostics };
23
+ };
24
+ export function createContractAdapter(opts) {
25
+ const { mod } = opts;
26
+ const run = async (input) => {
27
+ const warnings = [];
28
+ const onWarning = (w) => void warnings.push(w);
29
+ switch (input.leg) {
30
+ case "domains":
31
+ try {
32
+ const effective = mod.buildEffective(input.version, (d) => input.docs[d], input.updatedAt, onWarning);
33
+ return { outcome: "effective", effective, warnings: warnings };
34
+ }
35
+ catch (e) {
36
+ return reject(e, "validate");
37
+ }
38
+ case "wire":
39
+ try {
40
+ const effective = mod.readEffectiveWire(input.payload, onWarning);
41
+ return { outcome: "effective", effective, warnings: warnings };
42
+ }
43
+ catch (e) {
44
+ return reject(e, "envelope");
45
+ }
46
+ case "localRoot": {
47
+ const { root, cleanup } = await materializeLocalRoot(input.files, input.mtime);
48
+ try {
49
+ const lc = await mod.loadLocalConfig(root, { onWarning });
50
+ return { outcome: "effective", effective: lc.effective, warnings: lc.warnings };
51
+ }
52
+ catch (e) {
53
+ return reject(e, "parse");
54
+ }
55
+ finally {
56
+ await cleanup();
57
+ }
58
+ }
59
+ default: {
60
+ const unhandled = input;
61
+ throw new Error(`conformance: unknown leg input ${JSON.stringify(unhandled)}`);
62
+ }
63
+ }
64
+ };
65
+ return {
66
+ name: opts.name,
67
+ provenance: opts.provenance,
68
+ legs: {
69
+ domains: { entry: opts.entries.domains, run },
70
+ wire: { entry: opts.entries.wire, run },
71
+ localRoot: { entry: opts.entries.localRoot, run },
72
+ },
73
+ };
74
+ }