@sema-agent/settings-schema 2.0.0 → 3.0.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/CHANGELOG.md +307 -0
- package/README.md +35 -4
- package/conformance/VERSION +1 -0
- package/conformance/baseline.json +55 -0
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +105 -0
- package/conformance/cases/c01-entry-salvage-then-typo/input.json +22 -0
- package/conformance/cases/c01-entry-salvage-then-typo/meta.json +10 -0
- package/conformance/cases/c02-hosts-clamp-stacked/expected.json +96 -0
- package/conformance/cases/c02-hosts-clamp-stacked/input.json +21 -0
- package/conformance/cases/c02-hosts-clamp-stacked/meta.json +10 -0
- package/conformance/cases/c03-literal-dotted-top-key/expected.json +112 -0
- package/conformance/cases/c03-literal-dotted-top-key/input.json +26 -0
- package/conformance/cases/c03-literal-dotted-top-key/meta.json +5 -0
- package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +77 -0
- package/conformance/cases/c04-all-bad-collection-not-salvaged/input.json +18 -0
- package/conformance/cases/c04-all-bad-collection-not-salvaged/meta.json +5 -0
- package/conformance/cases/c05-gate-domain-bad-entry-throws/expected.json +16 -0
- package/conformance/cases/c05-gate-domain-bad-entry-throws/input.json +18 -0
- package/conformance/cases/c05-gate-domain-bad-entry-throws/meta.json +5 -0
- package/conformance/cases/c06-legacy-governance-lift/expected.json +99 -0
- package/conformance/cases/c06-legacy-governance-lift/input.json +20 -0
- package/conformance/cases/c06-legacy-governance-lift/meta.json +9 -0
- package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +72 -0
- package/conformance/cases/c07-hooks-is-not-a-config-domain/input.json +43 -0
- package/conformance/cases/c07-hooks-is-not-a-config-domain/meta.json +6 -0
- package/conformance/cases/c08-limits-unknown-carried-64/expected.json +336 -0
- package/conformance/cases/c08-limits-unknown-carried-64/input.json +74 -0
- package/conformance/cases/c08-limits-unknown-carried-64/meta.json +5 -0
- package/conformance/cases/c09-limits-unknown-carried-65/expected.json +338 -0
- package/conformance/cases/c09-limits-unknown-carried-65/input.json +75 -0
- package/conformance/cases/c09-limits-unknown-carried-65/meta.json +7 -0
- package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +106 -0
- package/conformance/cases/c10-record-key-secret-redacted-twice/input.json +29 -0
- package/conformance/cases/c10-record-key-secret-redacted-twice/meta.json +13 -0
- package/conformance/cases/c11-unicode-unknown-keys/expected.json +88 -0
- package/conformance/cases/c11-unicode-unknown-keys/input.json +13 -0
- package/conformance/cases/c11-unicode-unknown-keys/meta.json +5 -0
- package/conformance/cases/c12-prototype-name-top-keys/expected.json +72 -0
- package/conformance/cases/c12-prototype-name-top-keys/input.json +20 -0
- package/conformance/cases/c12-prototype-name-top-keys/meta.json +11 -0
- package/conformance/cases/c13-models-field-null/expected.json +77 -0
- package/conformance/cases/c13-models-field-null/input.json +11 -0
- package/conformance/cases/c13-models-field-null/meta.json +5 -0
- package/conformance/cases/c14-models-field-empty-array/expected.json +72 -0
- package/conformance/cases/c14-models-field-empty-array/input.json +11 -0
- package/conformance/cases/c14-models-field-empty-array/meta.json +5 -0
- package/conformance/cases/c15-models-field-number/expected.json +77 -0
- package/conformance/cases/c15-models-field-number/input.json +11 -0
- package/conformance/cases/c15-models-field-number/meta.json +5 -0
- package/conformance/cases/c16-deep-nesting-205/expected.json +83 -0
- package/conformance/cases/c16-deep-nesting-205/input.json +421 -0
- package/conformance/cases/c16-deep-nesting-205/meta.json +5 -0
- package/conformance/cases/c17-empty-domain-file/expected.json +73 -0
- package/conformance/cases/c17-empty-domain-file/input.json +10 -0
- package/conformance/cases/c17-empty-domain-file/meta.json +5 -0
- package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +74 -0
- package/conformance/cases/c18-wire-envelope-not-an-object/input.json +8 -0
- package/conformance/cases/c18-wire-envelope-not-an-object/meta.json +5 -0
- package/conformance/cases/c19-wire-envelope-no-version/expected.json +74 -0
- package/conformance/cases/c19-wire-envelope-no-version/input.json +13 -0
- package/conformance/cases/c19-wire-envelope-no-version/meta.json +5 -0
- package/conformance/cases/c20-governance-absent-lifts/expected.json +84 -0
- package/conformance/cases/c20-governance-absent-lifts/input.json +11 -0
- package/conformance/cases/c20-governance-absent-lifts/meta.json +9 -0
- package/conformance/cases/c21-governance-empty-object-wins/expected.json +72 -0
- package/conformance/cases/c21-governance-empty-object-wins/input.json +12 -0
- package/conformance/cases/c21-governance-empty-object-wins/meta.json +5 -0
- package/conformance/cases/c22-governance-null-throws/expected.json +16 -0
- package/conformance/cases/c22-governance-null-throws/input.json +9 -0
- package/conformance/cases/c22-governance-null-throws/meta.json +5 -0
- package/conformance/cases/c23-mixed-validity-models/expected.json +106 -0
- package/conformance/cases/c23-mixed-validity-models/input.json +27 -0
- package/conformance/cases/c23-mixed-validity-models/meta.json +9 -0
- package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +100 -0
- package/conformance/cases/c24-valid-entries-with-unknown-keys/input.json +25 -0
- package/conformance/cases/c24-valid-entries-with-unknown-keys/meta.json +5 -0
- package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +89 -0
- package/conformance/cases/c25-published-roundtrip-zero-warnings/input.json +24 -0
- package/conformance/cases/c25-published-roundtrip-zero-warnings/meta.json +5 -0
- package/conformance/cases/c26-hosts-array-1024/expected.json +7241 -0
- package/conformance/cases/c26-hosts-array-1024/input.json +3084 -0
- package/conformance/cases/c26-hosts-array-1024/meta.json +5 -0
- package/conformance/cases/c27-hosts-array-1025/expected.json +7248 -0
- package/conformance/cases/c27-hosts-array-1025/input.json +3087 -0
- package/conformance/cases/c27-hosts-array-1025/meta.json +5 -0
- package/conformance/cases/c28-large-document-over-1mb/expected.json +82 -0
- package/conformance/cases/c28-large-document-over-1mb/input.json +11 -0
- package/conformance/cases/c28-large-document-over-1mb/meta.json +5 -0
- package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +77 -0
- package/conformance/cases/c29-salvage-then-domain-refine-fails/input.json +27 -0
- package/conformance/cases/c29-salvage-then-domain-refine-fails/meta.json +5 -0
- package/conformance/cases/c30-version-triple-a-first/expected.json +85 -0
- package/conformance/cases/c30-version-triple-a-first/input.json +18 -0
- package/conformance/cases/c30-version-triple-a-first/meta.json +5 -0
- package/conformance/cases/c31-version-triple-b-changed/expected.json +85 -0
- package/conformance/cases/c31-version-triple-b-changed/input.json +18 -0
- package/conformance/cases/c31-version-triple-b-changed/meta.json +5 -0
- package/conformance/cases/c32-version-triple-a-restored/expected.json +85 -0
- package/conformance/cases/c32-version-triple-a-restored/input.json +18 -0
- package/conformance/cases/c32-version-triple-a-restored/meta.json +5 -0
- package/conformance/cases/c33-limits-nested-carried-key/expected.json +91 -0
- package/conformance/cases/c33-limits-nested-carried-key/input.json +14 -0
- package/conformance/cases/c33-limits-nested-carried-key/meta.json +5 -0
- package/conformance/cases/c34-skills-content-vs-manifest/expected.json +160 -0
- package/conformance/cases/c34-skills-content-vs-manifest/input.json +18 -0
- package/conformance/cases/c34-skills-content-vs-manifest/meta.json +5 -0
- package/conformance/cases/c35-portable-domain-file-is-null/expected.json +76 -0
- package/conformance/cases/c35-portable-domain-file-is-null/input.json +9 -0
- package/conformance/cases/c35-portable-domain-file-is-null/meta.json +5 -0
- package/conformance/cases/c36-skills-entry-without-content/expected.json +144 -0
- package/conformance/cases/c36-skills-entry-without-content/input.json +16 -0
- package/conformance/cases/c36-skills-entry-without-content/meta.json +5 -0
- package/conformance/cases/c37-skills-field-null/expected.json +134 -0
- package/conformance/cases/c37-skills-field-null/input.json +9 -0
- package/conformance/cases/c37-skills-field-null/meta.json +5 -0
- package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +72 -0
- package/conformance/cases/c38-remote-exec-is-a-non-domain-file/input.json +11 -0
- package/conformance/cases/c38-remote-exec-is-a-non-domain-file/meta.json +5 -0
- package/conformance/cases/c39-hidden-file-in-config-d/expected.json +72 -0
- package/conformance/cases/c39-hidden-file-in-config-d/input.json +11 -0
- package/conformance/cases/c39-hidden-file-in-config-d/meta.json +5 -0
- package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +112 -0
- package/conformance/cases/cc01-mcp-toolfaces-non-object/input.json +39 -0
- package/conformance/cases/cc01-mcp-toolfaces-non-object/meta.json +8 -0
- package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +85 -0
- package/conformance/cases/cc02-mcp-source-key-is-transported/input.json +20 -0
- package/conformance/cases/cc02-mcp-source-key-is-transported/meta.json +8 -0
- package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +89 -0
- package/conformance/cases/cc03-model-compat-unknown-subkey/input.json +22 -0
- package/conformance/cases/cc03-model-compat-unknown-subkey/meta.json +8 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +102 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/input.json +30 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/meta.json +9 -0
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +115 -0
- package/conformance/cases/cli02-control-and-zero-width-chars/input.json +42 -0
- package/conformance/cases/cli02-control-and-zero-width-chars/meta.json +9 -0
- package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +92 -0
- package/conformance/cases/cli03-credential-pasted-into-unknown-key/input.json +16 -0
- package/conformance/cases/cli03-credential-pasted-into-unknown-key/meta.json +9 -0
- package/conformance/cases/cli04-env-value-with-newline/expected.json +93 -0
- package/conformance/cases/cli04-env-value-with-newline/input.json +23 -0
- package/conformance/cases/cli04-env-value-with-newline/meta.json +5 -0
- package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +109 -0
- package/conformance/cases/cli05-long-and-prefix-colliding-names/input.json +30 -0
- package/conformance/cases/cli05-long-and-prefix-colliding-names/meta.json +5 -0
- package/conformance/cases/z01-every-domain-empty-object/expected.json +72 -0
- package/conformance/cases/z01-every-domain-empty-object/input.json +25 -0
- package/conformance/cases/z01-every-domain-empty-object/meta.json +5 -0
- package/conformance/cases/z02-every-domain-unknown-key/expected.json +228 -0
- package/conformance/cases/z02-every-domain-unknown-key/input.json +59 -0
- package/conformance/cases/z02-every-domain-unknown-key/meta.json +5 -0
- package/conformance/historical/settings-schema@1.0.0.json +999 -0
- package/conformance/historical/settings-schema@1.10.0.json +999 -0
- package/conformance/historical/settings-schema@1.5.0.json +999 -0
- package/conformance/historical/settings-schema@2.0.0.json +999 -0
- package/conformance/historical/settings-schema@2.1.0.json +999 -0
- package/dist/api/auth.d.ts +42 -1
- package/dist/api/auth.js +71 -4
- package/dist/api/scopes.d.ts +9 -1
- package/dist/api/scopes.js +8 -2
- package/dist/config-fns.d.ts +97 -23
- package/dist/config-fns.js +198 -46
- package/dist/conformance/canonical.d.ts +36 -0
- package/dist/conformance/canonical.js +114 -0
- package/dist/conformance/contract-adapter.d.ts +20 -0
- package/dist/conformance/contract-adapter.js +74 -0
- package/dist/conformance/corpus.d.ts +73 -0
- package/dist/conformance/corpus.js +99 -0
- package/dist/conformance/derive.d.ts +48 -0
- package/dist/conformance/derive.js +136 -0
- package/dist/conformance/index.d.ts +22 -0
- package/dist/conformance/index.js +22 -0
- package/dist/conformance/run.d.ts +27 -0
- package/dist/conformance/run.js +424 -0
- package/dist/conformance/types.d.ts +288 -0
- package/dist/conformance/types.js +4 -0
- package/dist/conformance/warnings.d.ts +88 -0
- package/dist/conformance/warnings.js +144 -0
- package/dist/file-store.d.ts +7 -0
- package/dist/file-store.js +6 -2
- package/package.json +7 -5
- package/dist/api/wire.d.ts +0 -60
- package/dist/api/wire.js +0 -89
package/dist/config-fns.js
CHANGED
|
@@ -157,6 +157,24 @@ 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
|
+
}
|
|
176
|
+
/** 一次「什么都没丢」的抢救的位移表(冻结的共享空表 —— {@link rebaseWarningPaths} 对它恒等)。 */
|
|
177
|
+
const NO_ENTRIES_DROPPED = Object.freeze({});
|
|
160
178
|
/** 一条条目的 parse —— 坏就返回它的(脱敏)错误。恶意 getter/Proxy 在遍历中抛错时**绝不**外泄其文案
|
|
161
179
|
* (可能带 secret,与 parseDomain 的 R13 同纪律),只留一句中性话。 */
|
|
162
180
|
function judgeEntry(schema, value) {
|
|
@@ -201,18 +219,19 @@ export function salvageEntries(schema, raw) {
|
|
|
201
219
|
return salvageEntriesUnsafe(schema, raw);
|
|
202
220
|
}
|
|
203
221
|
catch {
|
|
204
|
-
return { raw, dropped: [] }; // 反射/访问器抛错:抢不动就当没抢救,绝不外泄其文案
|
|
222
|
+
return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED }; // 反射/访问器抛错:抢不动就当没抢救,绝不外泄其文案
|
|
205
223
|
}
|
|
206
224
|
}
|
|
207
225
|
function salvageEntriesUnsafe(schema, raw) {
|
|
208
226
|
if (!isPlainJsonObject(raw))
|
|
209
|
-
return { raw, dropped: [] };
|
|
227
|
+
return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED };
|
|
210
228
|
const root = unwrapSchema(schema);
|
|
211
229
|
if (!(root instanceof z.ZodObject))
|
|
212
|
-
return { raw, dropped: [] };
|
|
230
|
+
return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED };
|
|
213
231
|
const shape = root.shape;
|
|
214
232
|
const dropped = [];
|
|
215
233
|
const patch = {};
|
|
234
|
+
const keptIndex = {};
|
|
216
235
|
for (const [field, fieldSchema] of Object.entries(shape)) {
|
|
217
236
|
const value = raw[field];
|
|
218
237
|
if (value === undefined)
|
|
@@ -221,17 +240,24 @@ function salvageEntriesUnsafe(schema, raw) {
|
|
|
221
240
|
if (collection instanceof z.ZodArray && Array.isArray(value)) {
|
|
222
241
|
const element = collection.element;
|
|
223
242
|
const bad = [];
|
|
243
|
+
const keptFrom = [];
|
|
244
|
+
// `filter`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length`
|
|
245
|
+
// 会跟着一个在校验期间自我增殖的数组一路跑下去(病态 getter 在 zod 读字段时往同一个数组里再 push
|
|
246
|
+
// 一条)——抢救是尽力而为的旁路,绝不许把同步读挂死。下标在回调里收,`filter` 本来就给 index。
|
|
224
247
|
const kept = value.filter((entry, i) => {
|
|
225
248
|
const error = judgeEntry(element, entry);
|
|
226
|
-
if (error === undefined)
|
|
249
|
+
if (error === undefined) {
|
|
250
|
+
keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
|
|
227
251
|
return true;
|
|
228
|
-
|
|
252
|
+
}
|
|
253
|
+
bad.push({ path: [field, i], error });
|
|
229
254
|
return false;
|
|
230
255
|
});
|
|
231
256
|
// 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
|
|
232
257
|
if (bad.length > 0 && kept.length > 0) {
|
|
233
258
|
dropped.push(...bad);
|
|
234
259
|
patch[field] = kept;
|
|
260
|
+
keptIndex[field] = keptFrom; // 只在真压缩了的字段上登记(见 {@link EntryKeptIndex})
|
|
235
261
|
}
|
|
236
262
|
}
|
|
237
263
|
else if (collection instanceof z.ZodRecord && isPlainJsonObject(value)) {
|
|
@@ -244,7 +270,7 @@ function salvageEntriesUnsafe(schema, raw) {
|
|
|
244
270
|
if (error === undefined)
|
|
245
271
|
kept[key] = entry;
|
|
246
272
|
else
|
|
247
|
-
bad.push({ path:
|
|
273
|
+
bad.push({ path: [field, redactSecrets(key)], error }); // 键是用户写的,可能就是一条粘错位置的凭据
|
|
248
274
|
}
|
|
249
275
|
if (bad.length > 0 && Object.keys(kept).length > 0) {
|
|
250
276
|
dropped.push(...bad);
|
|
@@ -253,8 +279,8 @@ function salvageEntriesUnsafe(schema, raw) {
|
|
|
253
279
|
}
|
|
254
280
|
}
|
|
255
281
|
if (dropped.length === 0)
|
|
256
|
-
return { raw, dropped };
|
|
257
|
-
return { raw: { ...raw, ...patch }, dropped };
|
|
282
|
+
return { raw, dropped, keptIndex: NO_ENTRIES_DROPPED };
|
|
283
|
+
return { raw: { ...raw, ...patch }, dropped, keptIndex };
|
|
258
284
|
}
|
|
259
285
|
/**
|
|
260
286
|
* Shared GC predicate for worker-status rows (F10) — keeps memory/sqlite/tidb identical. Given the rows'
|
|
@@ -295,6 +321,93 @@ export function legacyGovernanceFromRuntime(rawRuntime) {
|
|
|
295
321
|
lifted[k] = r[k];
|
|
296
322
|
return Object.keys(lifted).length ? lifted : undefined;
|
|
297
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* 一条告警里的**段坐标**({@link ConfigPath})从「抢救压缩后的坐标」回写成「原文档的坐标」—— 纯函数,
|
|
326
|
+
* {@link EntryKeptIndex} 是它唯一的坐标账。
|
|
327
|
+
*
|
|
328
|
+
* 成案(S-323,消费方真案):整域 parse 失败后 {@link salvageEntries} 会把坏条目从数组里剔掉,之后的
|
|
329
|
+
* 诊断({@link droppedKeys} / {@link carriedUnknownKeys})是拿**压缩后**的文档做键集对比出来的,它报的
|
|
330
|
+
* `models.1` 指的是压缩后的第 1 条。运维照着这条告警去改原文档的第 1 条 —— 改的是别人的行,而且改完
|
|
331
|
+
* 告警还在(他以为改对了)。**告警必须说运维看得见的那份文档里的坐标**。
|
|
332
|
+
*
|
|
333
|
+
* 为什么回写在这里、而不是让扫描函数认识原文档:给 `scanDropped` / `scanCarried` 加一个「原始 raw」
|
|
334
|
+
* 第二入参,等于让扫描同时持有两套坐标,每条新告警都要自己决定用哪套。回写是**告警出门前**的一次
|
|
335
|
+
* 坐标换算,与诊断本身正交 —— 一个铸点,所有臂一次表态。
|
|
336
|
+
*
|
|
337
|
+
* **闭集穷举**:`switch` 覆盖 {@link EffectiveReadWarning} 的每个 `kind`,新 kind 不声明处置 = 编译红
|
|
338
|
+
* (#157 的闭集纪律)。只分两类,没有第三类:**带坐标的回写,不带坐标的原样返回**。
|
|
339
|
+
*
|
|
340
|
+
* 为什么 `entry-dropped` 也回写:它**不是**「恒为原坐标」,只是不经**铸它那一层自己**的表 —— 上面那条律
|
|
341
|
+
* 说的就是这件事。{@link salvageEntries} 铸 `entry-dropped` 时用的是它**入参**那份文档的坐标;如果那份
|
|
342
|
+
* 文档本身已被外层(hosts 的 {@link grandfatherHostsRead})压缩过,外层照样要回写它一次。把「不动」写成
|
|
343
|
+
* 一条臂的性质,就是把「哪一层」这件事错记在了「哪个 kind」上。
|
|
344
|
+
*
|
|
345
|
+
* **恒等纪律**:没有任何一条路径真的变了(含 `keptIndex` 为空)⇒ 返回**同一个对象引用**,连 `keys`
|
|
346
|
+
* 数组也不复制(与 {@link grandfatherHostsRead} 的「没夹紧就返回原引用」同一条 byte 不变律)。
|
|
347
|
+
*/
|
|
348
|
+
export function rebaseWarningPaths(w, keptIndex) {
|
|
349
|
+
switch (w.kind) {
|
|
350
|
+
case "unknown-keys-dropped":
|
|
351
|
+
case "unknown-keys-carried":
|
|
352
|
+
case "legacy-governance-lifted": {
|
|
353
|
+
const keys = rebasePaths(w.keys, keptIndex);
|
|
354
|
+
return keys === w.keys ? w : { ...w, keys };
|
|
355
|
+
}
|
|
356
|
+
case "entry-dropped": {
|
|
357
|
+
const path = rebasePath(w.path, keptIndex);
|
|
358
|
+
return path === w.path ? w : { ...w, path };
|
|
359
|
+
}
|
|
360
|
+
// 以下三臂不带任何坐标:`hosts-grandfathered` 点名的是主机**名**,另两条点名的是错误/文件名。
|
|
361
|
+
case "hosts-grandfathered":
|
|
362
|
+
case "domain-defaulted":
|
|
363
|
+
case "unread-config-file":
|
|
364
|
+
return w;
|
|
365
|
+
default: {
|
|
366
|
+
// 闭集穷举门:新 kind 必须在上面表态,否则这里编译红。运行期到不了(本联合由本模块铸造);万一
|
|
367
|
+
// 到了就**原样返回** —— 回写是诊断面的尽力而为,这里抛异常会被调用点的 catch 吞成「整域降级」,
|
|
368
|
+
// 那比不回写坏得多(一条不认识的告警 ⇒ 整张目录蒸发)。
|
|
369
|
+
const unhandled = w;
|
|
370
|
+
return unhandled;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** 一组坐标的回写。全部原样 ⇒ 返回**原数组引用**(调用方据此判「什么都没变」)。 */
|
|
375
|
+
function rebasePaths(keys, keptIndex) {
|
|
376
|
+
let changed = false;
|
|
377
|
+
const out = keys.map((k) => {
|
|
378
|
+
const rebased = rebasePath(k, keptIndex);
|
|
379
|
+
if (rebased !== k)
|
|
380
|
+
changed = true;
|
|
381
|
+
return rebased;
|
|
382
|
+
});
|
|
383
|
+
return changed ? out : keys;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* `[field, N, ...rest]` → `[field, 原 N, ...rest]`。**一条律**(S-323c,3.0.0):`p[0]` 是字符串且在位移表里
|
|
387
|
+
* (= 那个数组字段真被压缩过)∧ `p[1]` 是**数字段** ⇔ 它是一条数组路径。不满足 ⇒ 原样。
|
|
388
|
+
*
|
|
389
|
+
* 2.1.0 在点号串上需要三道门(字段在表里 / 段数合它那一支文法 / 下标段是规范十进制),因为串把「这一段
|
|
390
|
+
* 是下标还是键名」这个位丢了,只能靠文法猜回来;段形把那个位带在类型里,三道门收成一道 —— 判它是正确
|
|
391
|
+
* 抽象的第一判据就是**规则数变少**(`PathGrammar`、段数门、规范十进制正则三件随之删除)。
|
|
392
|
+
*
|
|
393
|
+
* 字面含点的顶层键(`["models.1.k"]`)天然不受影响:它只有一段,`p[1]` 缺席。Record 形的条目坐标
|
|
394
|
+
* (`[field, key]`)同样:Record 字段从不进位移表(条目按键寻址,丢一条不动其余键的坐标),而它的第二段
|
|
395
|
+
* 是字符串键、不是下标 —— 两道判据各自都挡得住。
|
|
396
|
+
*
|
|
397
|
+
* **恒等纪律**:没有任何一段真的变了(表长外、或原下标与压缩后下标相同)⇒ 返回**原引用**,不复制。
|
|
398
|
+
*/
|
|
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)];
|
|
410
|
+
}
|
|
298
411
|
/** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
|
|
299
412
|
const DROP_SCAN_MAX_DEPTH = 8;
|
|
300
413
|
/** 一条警告最多点名多少个键(告警载荷有界;超界时警告带 `truncatedFrom` = 真实总数,见 {@link droppedKeys})。 */
|
|
@@ -307,22 +420,32 @@ const DROP_SCAN_MAX_KEYS = 64;
|
|
|
307
420
|
* 满额特判,四层各被下一轮复审打穿)。意图只能在**声明处**说清:写者在这里登记自己的命名空间,之后
|
|
308
421
|
* 所有未被登记的未知键都是拼错,消费方一条规则:未知键 = 错误,零特例。
|
|
309
422
|
*
|
|
310
|
-
* 登记形(分段):`at` =
|
|
423
|
+
* 登记形(分段):`at` = 父路径的**段模式**({@link ConfigPath} 的形加一个通配哨兵 {@link ANY_INDEX} =
|
|
424
|
+
* 任一数组下标,顶层 = `[]`;逐段全等,天然闭合——没有正则可以锚歪,test 有门)+ `key` = 键名前缀正则;
|
|
425
|
+
* `owner` = 写者;
|
|
311
426
|
* `since` = 契约出处。新写者/新层级来这里加行,并在 CHANGELOG 点名——这是 additive(minor)。
|
|
312
427
|
*
|
|
313
428
|
* · `models` 条目级 `sema[A-Z]…`:壳(cli)的自留字段族(`semaChannel`/`semaLabel`/`semaProbe`/`semaProbeAt`/
|
|
314
429
|
* `semaEffortDefault`/`semaVision`,壳侧 models.json 实键清单 design/cli-190c:「`sema` 前缀是壳侧自留字段的既定
|
|
315
430
|
* 约定」,**全条目级、顶层零装饰键**)。顶层 `sema*` 不在此列 ⇒ 仍是未知键(拼错或越界)。
|
|
316
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");
|
|
317
439
|
export const EXTENSION_NAMESPACES = {
|
|
318
|
-
models: [{ at:
|
|
440
|
+
models: [{ at: ["models", ANY_INDEX], key: /^sema[A-Z]/, owner: "cli", since: "shell key census design/cli-190c (2026-08-26)" }],
|
|
319
441
|
};
|
|
320
|
-
/** 该键是否落在 {@link EXTENSION_NAMESPACES} 登记的扩展命名空间里(⇒ 不是未知键)。**分段匹配**:`at`
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
|
|
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) {
|
|
324
447
|
const rules = EXTENSION_NAMESPACES[domain];
|
|
325
|
-
return rules !== undefined && rules.some((r) => r.at.
|
|
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));
|
|
326
449
|
}
|
|
327
450
|
/** 纯 JSON 对象(不是数组、不是类实例/带原型的东西)——只对这种做键集对比,别的形状交给 parse 判。 */
|
|
328
451
|
function isPlainJsonObject(v) {
|
|
@@ -339,7 +462,7 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
|
|
|
339
462
|
if (raw.length !== parsed.length)
|
|
340
463
|
return;
|
|
341
464
|
for (let i = 0; i < raw.length; i++)
|
|
342
|
-
collectDropped(domain, raw[i], parsed[i],
|
|
465
|
+
collectDropped(domain, raw[i], parsed[i], [...prefix, i], depth + 1, [], acc);
|
|
343
466
|
return;
|
|
344
467
|
}
|
|
345
468
|
if (!isPlainJsonObject(raw) || !isPlainJsonObject(parsed))
|
|
@@ -352,16 +475,16 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
|
|
|
352
475
|
continue; // 扩展命名空间:按契约放的键,不是未知键
|
|
353
476
|
acc.total += 1;
|
|
354
477
|
if (acc.keys.length < DROP_SCAN_MAX_KEYS)
|
|
355
|
-
acc.keys.push(prefix
|
|
478
|
+
acc.keys.push([...prefix, k]);
|
|
356
479
|
continue;
|
|
357
480
|
}
|
|
358
|
-
collectDropped(domain, raw[k], parsed[k],
|
|
481
|
+
collectDropped(domain, raw[k], parsed[k], [...prefix, k], depth + 1, spared, acc);
|
|
359
482
|
}
|
|
360
483
|
}
|
|
361
484
|
function scanDropped(domain, raw, parsed) {
|
|
362
485
|
const acc = { keys: [], total: 0 };
|
|
363
486
|
try {
|
|
364
|
-
collectDropped(domain, raw, parsed,
|
|
487
|
+
collectDropped(domain, raw, parsed, [], 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], acc);
|
|
365
488
|
}
|
|
366
489
|
catch {
|
|
367
490
|
return { keys: [], total: 0 }; // 反射/访问器抛错:告警是尽力而为的审计线,绝不能把解析拖下水,更不能回显其错误文案
|
|
@@ -369,7 +492,7 @@ function scanDropped(domain, raw, parsed) {
|
|
|
369
492
|
return acc;
|
|
370
493
|
}
|
|
371
494
|
/**
|
|
372
|
-
* 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`)
|
|
495
|
+
* 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),段坐标形({@link ConfigPath})。
|
|
373
496
|
*
|
|
374
497
|
* 成案(消费方 #322 真事故):往 `runtime` 文档里加一个不属于该域的键,两条腿都会把它剥掉,
|
|
375
498
|
* `domainErrors` 空、解析"成功"——配置写了、值没到、零告警。剥除行为本身是**刻意的前向兼容**
|
|
@@ -392,8 +515,8 @@ export function droppedKeys(domain, raw, parsed) {
|
|
|
392
515
|
return scanDropped(domain, raw, parsed).keys;
|
|
393
516
|
}
|
|
394
517
|
/**
|
|
395
|
-
* open-world 域(`.passthrough()`)
|
|
396
|
-
* `shape` **单源派生**(手抄一份必然漂)
|
|
518
|
+
* open-world 域(`.passthrough()`)的**已知键表**:段坐标 → 该层已知键(`[]` = 顶层)。键集从 schema 的
|
|
519
|
+
* `shape` **单源派生**(手抄一份必然漂),层级表本身是显式的——嵌套只在真有 open-world 子对象的地方登记。
|
|
397
520
|
*
|
|
398
521
|
* 为什么需要它(对抗复审 复审 round2 F2):承运解决了「值到不了」,但没解决「键名拼错了没人说」——
|
|
399
522
|
* `toolResultsTtlSec`(多一个 s)会完好穿过本包,而消费方只遍历自己的键表,于是那条天花板悄悄没生效。
|
|
@@ -403,10 +526,13 @@ export function droppedKeys(domain, raw, parsed) {
|
|
|
403
526
|
* `unknownKeys === "passthrough"` 的域 schema 都必须在本表里有行(缺行 = 该域的未知键无人点名)。
|
|
404
527
|
*/
|
|
405
528
|
const OPEN_WORLD_KNOWN_KEYS = {
|
|
406
|
-
limits:
|
|
529
|
+
limits: [
|
|
530
|
+
[[], Object.keys(LimitsConfig.shape)],
|
|
531
|
+
[["infraCostRates"], Object.keys(InfraCostRates.shape)],
|
|
532
|
+
],
|
|
407
533
|
};
|
|
408
534
|
/**
|
|
409
|
-
* 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(
|
|
535
|
+
* 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(段坐标)。非 open-world 域恒空
|
|
410
536
|
* (它们的未知键走 {@link droppedKeys} 那条更重的「被丢了」告警)。
|
|
411
537
|
*
|
|
412
538
|
* 边界(如实):只看 {@link OPEN_WORLD_KNOWN_KEYS} 登记了的层级 —— 未登记层级(如某个未知键自己的
|
|
@@ -418,21 +544,21 @@ export function carriedUnknownKeys(domain, parsed) {
|
|
|
418
544
|
function scanCarried(domain, parsed) {
|
|
419
545
|
const table = OPEN_WORLD_KNOWN_KEYS[domain];
|
|
420
546
|
const acc = { keys: [], total: 0 };
|
|
421
|
-
if (
|
|
547
|
+
if (table === undefined)
|
|
422
548
|
return acc;
|
|
423
|
-
for (const [
|
|
424
|
-
|
|
549
|
+
for (const [at, known] of table) {
|
|
550
|
+
// 段形下探:`[]` 落在文档根上,所以「顶层 vs 嵌套」不再是两条分支(规则少一条)。
|
|
551
|
+
const node = at.reduce((cur, seg) => (isPlainJsonObject(cur) ? cur[String(seg)] : undefined), parsed);
|
|
425
552
|
if (!isPlainJsonObject(node))
|
|
426
553
|
continue;
|
|
427
|
-
const prefix = path === "" ? "" : `${path}.`;
|
|
428
554
|
for (const k of Object.keys(node)) {
|
|
429
555
|
if (known.includes(k))
|
|
430
556
|
continue;
|
|
431
|
-
if (isReservedExtensionKey(domain,
|
|
557
|
+
if (isReservedExtensionKey(domain, at, k))
|
|
432
558
|
continue;
|
|
433
559
|
acc.total += 1;
|
|
434
560
|
if (acc.keys.length < DROP_SCAN_MAX_KEYS)
|
|
435
|
-
acc.keys.push(
|
|
561
|
+
acc.keys.push([...at, k]);
|
|
436
562
|
}
|
|
437
563
|
}
|
|
438
564
|
return acc;
|
|
@@ -516,16 +642,18 @@ export const DOMAIN_READ_FALLBACK = {
|
|
|
516
642
|
* observable: a deployment that has not seen that warning across a full read cycle no longer needs the arm. */
|
|
517
643
|
export function grandfatherHostsRead(raw) {
|
|
518
644
|
if (raw === null || typeof raw !== "object" || !Array.isArray(raw.hosts)) {
|
|
519
|
-
return { raw, droppedNames: [] }; // not the expected shape — let parseDomain rule on it
|
|
645
|
+
return { raw, droppedNames: [], keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
|
|
520
646
|
}
|
|
521
647
|
const original = raw.hosts;
|
|
522
648
|
const droppedNames = [];
|
|
523
|
-
|
|
649
|
+
const keptFrom = [];
|
|
650
|
+
let hosts = original.filter((h, i) => {
|
|
524
651
|
const name = h !== null && typeof h === "object" ? h.name : undefined;
|
|
525
652
|
if (typeof name === "string" && name.length > HOST_NAME_MAX) {
|
|
526
653
|
droppedNames.push(name.slice(0, HOST_NAME_MAX) + "…"); // audit label — clipped so the warning itself stays bounded
|
|
527
654
|
return false;
|
|
528
655
|
}
|
|
656
|
+
keptFrom.push(i); // 夹紧后第 keptFrom.length-1 条 = 原文档第 i 条
|
|
529
657
|
return true;
|
|
530
658
|
});
|
|
531
659
|
let truncatedFrom;
|
|
@@ -534,20 +662,37 @@ export function grandfatherHostsRead(raw) {
|
|
|
534
662
|
hosts = hosts.slice(0, HOSTS_ARRAY_MAX);
|
|
535
663
|
}
|
|
536
664
|
if (droppedNames.length === 0 && truncatedFrom === undefined)
|
|
537
|
-
return { raw, droppedNames }; // identity — byte-stable
|
|
538
|
-
|
|
665
|
+
return { raw, droppedNames, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
|
|
666
|
+
// 位移**只**来自按名剔条目;纯截断留下的是原数组前缀,幸存者下标不动 ⇒ 不登记(见 EntryKeptIndex)。
|
|
667
|
+
const keptIndex = droppedNames.length > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
|
|
668
|
+
return { raw: { ...raw, hosts }, droppedNames, truncatedFrom, keptIndex };
|
|
539
669
|
}
|
|
540
670
|
export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
541
671
|
// 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
|
|
542
672
|
// 不认识的键(都含嵌套,点号路径)点名(行为不变,只是不再无声)。判据与 `getDomain` 同源
|
|
543
673
|
// ({@link parseDomainLoud})。只在成功路径上报——降级路径已经有 domain-defaulted 那条更重的警告。
|
|
544
674
|
const parseLoud = (domain, raw) => parseDomainLoud(domain, raw, onWarning);
|
|
675
|
+
/**
|
|
676
|
+
* 一层读的**告警出口**,外面裹了那一层的坐标换算(S-323b 统一律)。
|
|
677
|
+
*
|
|
678
|
+
* 读路径上有**两个压缩点**:{@link grandfatherHostsRead}(parse 之前按名剔 host 条目)与
|
|
679
|
+
* {@link salvageEntries}(整域 parse 失败后剔坏条目)。律只有一条:**一条告警用「铸它的那一层所见
|
|
680
|
+
* 文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。** 组合因此是自然的
|
|
681
|
+
* ——内层先换算、外层后换算,不重不漏,而每一层都只需要知道**自己**那本账。
|
|
682
|
+
*
|
|
683
|
+
* 位移表**空**(这一层这次什么都没压缩)⇒ 原样返回 `onWarning`,连一层闭包都不套 —— 与
|
|
684
|
+
* {@link rebaseWarningPaths} 的恒等纪律同一条律:没发生的事不留痕迹。
|
|
685
|
+
*/
|
|
686
|
+
const exitOf = (keptIndex) => onWarning === undefined || Object.keys(keptIndex).length === 0 ? onWarning : (w) => onWarning(rebaseWarningPaths(w, keptIndex));
|
|
545
687
|
// Generic READ guardrail (0.8.1): a domain that no longer parses degrades to its schema default + a warning
|
|
546
688
|
// — but ONLY where DOMAIN_READ_FALLBACK says the default is safe; gate-bearing domains keep throwing (决策
|
|
547
689
|
// 表与逐域理由见 DOMAIN_READ_FALLBACK). The success path is UNCHANGED (same parseDomain, same bytes).
|
|
548
|
-
|
|
690
|
+
//
|
|
691
|
+
// `emit` = 这一腿的告警出口(默认 = 本函数的 `onWarning`)。**刻意不收「外层压缩了多少」这种入参**:
|
|
692
|
+
// parseOr 不必知道自己上面还有没有一层,它只管把告警交给给它的那个出口 —— 换算是出口的事。
|
|
693
|
+
const parseOr = (domain, raw, emit = onWarning) => {
|
|
549
694
|
try {
|
|
550
|
-
return
|
|
695
|
+
return parseDomainLoud(domain, raw, emit);
|
|
551
696
|
}
|
|
552
697
|
catch (e) {
|
|
553
698
|
if (DOMAIN_READ_FALLBACK[domain] === "throw")
|
|
@@ -560,17 +705,20 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
560
705
|
try {
|
|
561
706
|
const buffered = [];
|
|
562
707
|
const parsed = parseDomainLoud(domain, salvage.raw, (w) => buffered.push(w));
|
|
708
|
+
// 统一律落地:`entry-dropped` 是**本层**(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒
|
|
709
|
+
// 不过本层自己的表,直接交给出口(出口会补上更外层的换算);`buffered` 里的是对**本层压缩后**
|
|
710
|
+
// 文档的诊断,是从下方上来的 ⇒ 先过本层的表,再交给出口。scan 函数一字不动。
|
|
563
711
|
for (const d of salvage.dropped)
|
|
564
|
-
|
|
712
|
+
emit?.({ domain, kind: "entry-dropped", path: d.path, error: d.error });
|
|
565
713
|
for (const w of buffered)
|
|
566
|
-
|
|
714
|
+
emit?.(rebaseWarningPaths(w, salvage.keptIndex));
|
|
567
715
|
return parsed;
|
|
568
716
|
}
|
|
569
717
|
catch {
|
|
570
718
|
// 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它,照常整域降级。
|
|
571
719
|
}
|
|
572
720
|
}
|
|
573
|
-
|
|
721
|
+
emit?.({ domain, kind: "domain-defaulted", error: e });
|
|
574
722
|
return parseDomain(domain, undefined); // schema default — always valid
|
|
575
723
|
}
|
|
576
724
|
};
|
|
@@ -582,7 +730,7 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
582
730
|
const lifted = legacyGovernanceFromRuntime(getRaw("runtime"));
|
|
583
731
|
// 兼容臂真生效时出声(1.11.0):行为一字不变,但窗要能关就得有人看得见它还开着。
|
|
584
732
|
if (lifted !== undefined)
|
|
585
|
-
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]) });
|
|
586
734
|
governanceSource = lifted;
|
|
587
735
|
}
|
|
588
736
|
const governance = parseLoud("governance", governanceSource);
|
|
@@ -609,7 +757,9 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
609
757
|
systems: parseOr("systems", getRaw("systems")),
|
|
610
758
|
collab: parseOr("collab", getRaw("collab")),
|
|
611
759
|
workers: parseLoud("workers", getRaw("workers")),
|
|
612
|
-
|
|
760
|
+
// hosts 是读路径上**唯一**有外层压缩点的腿:`hostsGf.raw` 已被 grandfather 按名剔过条目,所以这一腿的
|
|
761
|
+
// 告警出口要裹上它的位移表(统一律:哪一层压缩,哪一层回写从它下方上来的告警)。
|
|
762
|
+
hosts: parseOr("hosts", hostsGf.raw, exitOf(hostsGf.keptIndex)),
|
|
613
763
|
runtime,
|
|
614
764
|
governance,
|
|
615
765
|
entitlement: parseLoud("entitlement", getRaw("entitlement")),
|
|
@@ -679,11 +829,13 @@ export function readEffectiveWire(raw, onWarning) {
|
|
|
679
829
|
}
|
|
680
830
|
}
|
|
681
831
|
// Open-world passthrough: keys that are neither validated domains nor the envelope fields ride verbatim.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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"));
|
|
687
839
|
// prompts:剥掉 buildEffective 恒物化的 {bindings:[]} 底座,按 wire 在场性 verbatim 挂回(#100:
|
|
688
840
|
// 缺席进缺席出——center worker-lane 闸关是常态形,物化会让消费端「缺席」分支永不可达)。
|
|
689
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;
|