@sema-agent/settings-schema 12.2.1 → 13.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 +223 -0
- package/README.md +41 -6
- package/conformance/VERSION +1 -1
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +1 -3
- package/conformance/cases/c03-literal-dotted-top-key/expected.json +4 -12
- package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +1 -3
- package/conformance/cases/c23-mixed-validity-models/expected.json +2 -6
- package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +2 -6
- package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +2 -6
- package/conformance/cases/c30-version-triple-a-first/expected.json +2 -6
- package/conformance/cases/c31-version-triple-b-changed/expected.json +2 -6
- package/conformance/cases/c32-version-triple-a-restored/expected.json +2 -6
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +1 -3
- package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +1 -3
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +2 -6
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +1 -3
- package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +1 -3
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +1 -3
- package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/expected.json +1 -3
- package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -18
- package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +1 -3
- package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +1 -3
- package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +1 -3
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +1 -3
- package/conformance/cases/c76-projects-entry-salvage-nested/expected.json +142 -0
- package/conformance/cases/c76-projects-entry-salvage-nested/input.json +54 -0
- package/conformance/cases/c76-projects-entry-salvage-nested/meta.json +6 -0
- package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +2 -6
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +1 -3
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +1 -3
- package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -18
- package/conformance/cases/t01-absent-domain-key-not-null/expected.json +1 -3
- package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +2 -6
- package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +1 -3
- package/dist/config-fns.d.ts +94 -8
- package/dist/config-fns.js +254 -116
- package/dist/hooks-config.d.ts +235 -0
- package/dist/hooks-config.js +458 -0
- package/dist/hooks.d.ts +16 -154
- package/dist/hooks.js +17 -111
- package/dist/http-hook-allowlists.d.ts +63 -0
- package/dist/http-hook-allowlists.js +73 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -1
- package/dist/mcp-server-policy.d.ts +75 -0
- package/dist/mcp-server-policy.js +84 -0
- package/dist/safety-merge-spec.d.ts +13 -3
- package/dist/safety-merge-spec.js +10 -3
- package/dist/settings-sources.d.ts +91 -0
- package/dist/settings-sources.js +107 -0
- package/dist/types.d.ts +6 -6
- package/dist/types.js +9 -5
- package/dist/ui-language.d.ts +40 -0
- package/dist/ui-language.js +37 -0
- package/dist/url-pattern.d.ts +33 -0
- package/dist/url-pattern.js +239 -0
- package/package.json +1 -1
package/dist/config-fns.js
CHANGED
|
@@ -419,78 +419,122 @@ function judgeEntry(schema, value, rejectedAs) {
|
|
|
419
419
|
* 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
|
|
420
420
|
* 绝不把解析拖下水、更不回显它的错误(与同文件 {@link droppedKeys} 逐字同纪律)。
|
|
421
421
|
*
|
|
422
|
-
*
|
|
422
|
+
* **递归**(12.3.0,S-1494):集合里的每一条,先对它自己按**同一个函数**抢救(它的 schema 若是带集合字段的对象 —— 从 schema 派生,
|
|
423
|
+
* 没有任何域专用分支),再拿抢救后的那一条去判整条。体内丢的子条目以父坐标为前缀上账(`["projects", <id>, "contextItems", 1]`),
|
|
424
|
+
* 体内压缩过的数组以全路径进位移表({@link EntryKeptIndex});整条最终仍判不过 ⇒ 整条丢,它体内的半路账一并作废(一条一个结论),
|
|
425
|
+
* 记账行的判决取**原文**的(拒句坐标是原文档的,不是抢救形里压缩过的下标)。
|
|
426
|
+
* 零幸存者律在**每一层**成立 —— 体内的集合全坏 ⇒ 不压成空集合,那一条照原样去判(于是整条丢):嵌套里同样有「空 = 不设限」的字段
|
|
427
|
+
* (`skills[].scenarios` / `mcp.servers[].allowTools` …),抢救成 `[]` 就是把限定范围放成全开。账按文档序排(父条目丢在哪个位置,
|
|
428
|
+
* 体内的子条目就排在哪个位置)。
|
|
429
|
+
*
|
|
430
|
+
* 边界(如实登记):① 只下钻**条目**(集合的元素 / 值),不下钻顶层的普通对象字段(`models.atMention` 不是条目);② raw 不是
|
|
423
431
|
* 纯 JSON 对象、字段值与 schema 形状对不上(schema 说数组、文档给对象)⇒ 原样返回,交给整域档位裁;
|
|
424
432
|
* ③ 域级判据(重名 / 悬空 ref)不在本函数视野内 —— 丢条目救不了它们,调用方据此回落整域档位;
|
|
425
|
-
* ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向)
|
|
433
|
+
* ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向);⑤ 递归深度止于 schema
|
|
434
|
+
* (本包没有自引用 schema);`z.lazy` 包着的 schema 不展开(`unwrapSchema` 不认它)⇒ 不下钻,那一条按原样判。
|
|
426
435
|
*/
|
|
427
436
|
export function salvageEntries(schema, raw) {
|
|
437
|
+
const layer = salvageLayer(schema, raw);
|
|
438
|
+
return { raw: layer.raw, dropped: layer.dropped, keptIndex: keptIndexOf(layer.shifts) };
|
|
439
|
+
}
|
|
440
|
+
/** 一层抢救,抢不动就当没抢救(反射 / 访问器抛错:绝不外泄其文案)。最外层与每一条条目体内走的是**同一只** —— 一条条目体内抢不动,
|
|
441
|
+
* 只是这一条不下钻、照原样去判,不连坐它所在的那一层。 */
|
|
442
|
+
function salvageLayer(schema, raw) {
|
|
428
443
|
try {
|
|
429
|
-
return
|
|
444
|
+
return salvageLayerUnsafe(schema, raw);
|
|
430
445
|
}
|
|
431
446
|
catch {
|
|
432
|
-
return { raw, dropped: [],
|
|
447
|
+
return { raw, dropped: [], shifts: [] };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** 集合字段的值 → 它的位置表;值的形与 schema 对不上(schema 说数组、文档给对象)⇒ `undefined`(不抢救,交给整域档位)。
|
|
451
|
+
* 数组走 `forEach`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length` 会跟着一个在校验期间
|
|
452
|
+
* 自我增殖的数组一路跑下去(病态 getter 在 zod 读字段时往同一个数组里再 push 一条)—— 抢救是尽力而为的旁路,绝不许把同步读挂死;
|
|
453
|
+
* 位置表在任何判定之前就收齐了,之后数组再怎么长都与本次抢救无关。`forEach` 跳过空洞,与此前的 `filter` 同。 */
|
|
454
|
+
function collectionSlots(value, isArray) {
|
|
455
|
+
if (isArray) {
|
|
456
|
+
if (!Array.isArray(value))
|
|
457
|
+
return undefined;
|
|
458
|
+
const slots = [];
|
|
459
|
+
value.forEach((entry, i) => void slots.push([i, entry]));
|
|
460
|
+
return slots;
|
|
433
461
|
}
|
|
462
|
+
return isPlainJsonObject(value) ? Object.entries(value) : undefined;
|
|
434
463
|
}
|
|
435
|
-
function
|
|
464
|
+
function salvageLayerUnsafe(schema, raw) {
|
|
465
|
+
const untouched = { raw, dropped: [], shifts: [] };
|
|
436
466
|
if (!isPlainJsonObject(raw))
|
|
437
|
-
return
|
|
467
|
+
return untouched;
|
|
438
468
|
const root = unwrapSchema(schema);
|
|
439
469
|
if (!(root instanceof z.ZodObject))
|
|
440
|
-
return
|
|
470
|
+
return untouched;
|
|
441
471
|
const shape = root.shape;
|
|
442
472
|
const dropped = [];
|
|
473
|
+
const shifts = [];
|
|
443
474
|
const patch = {};
|
|
444
|
-
const keptIndex = {};
|
|
445
475
|
for (const [field, fieldSchema] of Object.entries(shape)) {
|
|
446
|
-
const value = raw[field];
|
|
447
|
-
if (value === undefined)
|
|
448
|
-
continue;
|
|
449
476
|
const collection = unwrapSchema(fieldSchema);
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
dropped.push(...bad);
|
|
469
|
-
patch[field] = kept;
|
|
470
|
-
keptIndex[field] = keptFrom; // 只在真压缩了的字段上登记(见 {@link EntryKeptIndex})
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
else if (collection instanceof z.ZodRecord && isPlainJsonObject(value)) {
|
|
474
|
-
const keySchema = collection.keyType;
|
|
475
|
-
const valueSchema = collection.valueType;
|
|
476
|
-
const bad = [];
|
|
477
|
-
const kept = {};
|
|
478
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
479
|
-
const verdict = judgeEntry(keySchema, key, "entry-key-invalid") ?? judgeEntry(valueSchema, entry, "entry-schema-invalid");
|
|
480
|
-
if (verdict === undefined)
|
|
481
|
-
kept[key] = entry;
|
|
482
|
-
else
|
|
483
|
-
bad.push(droppedEntry(outgoingPath([field, key]), verdict, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
|
|
477
|
+
const isArray = collection instanceof z.ZodArray;
|
|
478
|
+
if (!isArray && !(collection instanceof z.ZodRecord))
|
|
479
|
+
continue; // 不是条目集合:连读都不读(少触发一次访问器)
|
|
480
|
+
const slots = collectionSlots(raw[field], isArray);
|
|
481
|
+
if (slots === undefined)
|
|
482
|
+
continue;
|
|
483
|
+
const element = (collection instanceof z.ZodArray ? collection.element : collection.valueType);
|
|
484
|
+
const keySchema = collection instanceof z.ZodRecord ? collection.keyType : undefined;
|
|
485
|
+
const found = []; // 本字段上账的:本层丢的整条 + 幸存条目体内丢的子条目,按文档序
|
|
486
|
+
const survivors = []; // 幸存条目(体内抢救过的,是它的抢救形)
|
|
487
|
+
const innerShifts = [];
|
|
488
|
+
for (const [address, entry] of slots) {
|
|
489
|
+
const at = outgoingPath([field, address]); // 键是用户写的,可能就是一条粘错位置的凭据
|
|
490
|
+
// Record 的键先判:键坏的条目不再算值,也不下钻。
|
|
491
|
+
const keyVerdict = keySchema === undefined ? undefined : judgeEntry(keySchema, address, "entry-key-invalid");
|
|
492
|
+
if (keyVerdict !== undefined) {
|
|
493
|
+
found.push(droppedEntry(at, keyVerdict, entry));
|
|
494
|
+
continue;
|
|
484
495
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
496
|
+
const inner = salvageLayer(element, entry); // 先下钻:这一条自己的集合按同一条律抢救
|
|
497
|
+
const verdict = judgeEntry(element, inner.raw, "entry-schema-invalid");
|
|
498
|
+
if (verdict !== undefined) {
|
|
499
|
+
// 整条丢 ⇒ 记账行说的是**原文**:身份取原文,判决也取原文的(拒句里的坐标是运维手里那份文档的 —— 抢救形的数组压缩过,
|
|
500
|
+
// 条目级判据点到体内下标时会报错位;坐标律 S-323b 在 `error` 这一面同样成立)。体内的半路账作废。体内真丢过东西的条目,原文必判不过
|
|
501
|
+
// (被丢的子条目本身就过不了它的 schema),`?? verdict` 只为类型。
|
|
502
|
+
found.push(droppedEntry(at, inner.raw === entry ? verdict : (judgeEntry(element, entry, "entry-schema-invalid") ?? verdict), entry));
|
|
503
|
+
continue;
|
|
488
504
|
}
|
|
505
|
+
survivors.push([address, inner.raw]);
|
|
506
|
+
for (const d of inner.dropped)
|
|
507
|
+
found.push({ ...d, path: [...at, ...d.path] });
|
|
508
|
+
for (const s of inner.shifts)
|
|
509
|
+
innerShifts.push({ at: [...at, ...s.at], kept: s.kept });
|
|
489
510
|
}
|
|
511
|
+
// 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合);什么都没上账 ⇒ 这个字段原样不动。
|
|
512
|
+
if (survivors.length === 0 || found.length === 0)
|
|
513
|
+
continue;
|
|
514
|
+
dropped.push(...found);
|
|
515
|
+
// fromEntries(不是逐键赋值):Record 的键是用户写的,`__proto__` 作键时逐键赋值会走原型 setter(S-332 同一条「键就是键」)。
|
|
516
|
+
patch[field] = isArray ? survivors.map(([, e]) => e) : Object.fromEntries(survivors);
|
|
517
|
+
const keptFrom = survivors.map(([i]) => i);
|
|
518
|
+
if (isArray && (keptFrom.length < slots.length || keptFrom.some((original, j) => original !== j)))
|
|
519
|
+
shifts.push({ at: outgoingPath([field]), kept: keptFrom });
|
|
520
|
+
shifts.push(...innerShifts);
|
|
490
521
|
}
|
|
491
522
|
if (dropped.length === 0)
|
|
492
|
-
return
|
|
493
|
-
return { raw: { ...raw, ...patch }, dropped,
|
|
523
|
+
return untouched;
|
|
524
|
+
return { raw: { ...raw, ...patch }, dropped, shifts };
|
|
525
|
+
}
|
|
526
|
+
/** 段坐标记的位移账 → {@link EntryKeptIndex}(键 = 全路径的出门串)。同一个键出现两次只可能是两只被打码成同一串的 Record 键
|
|
527
|
+
* (坐标本身已分不清是谁)⇒ 那个键不登记。什么都没压缩 ⇒ 冻结的共享空表。 */
|
|
528
|
+
function keptIndexOf(shifts) {
|
|
529
|
+
if (shifts.length === 0)
|
|
530
|
+
return NO_ENTRIES_DROPPED;
|
|
531
|
+
const byKey = new Map();
|
|
532
|
+
for (const s of shifts) {
|
|
533
|
+
const key = formatConfigPath(s.at);
|
|
534
|
+
byKey.set(key, byKey.has(key) ? undefined : s.kept);
|
|
535
|
+
}
|
|
536
|
+
// fromEntries:键是出门串,含用户写的 Record 键段 —— 不走原型 setter。
|
|
537
|
+
return Object.fromEntries([...byKey].filter((e) => e[1] !== undefined));
|
|
494
538
|
}
|
|
495
539
|
/**
|
|
496
540
|
* Shared GC predicate for worker-status rows (F10) — keeps memory/sqlite/tidb identical. Given the rows'
|
|
@@ -617,30 +661,36 @@ function rebasePaths(keys, keptIndex) {
|
|
|
617
661
|
return changed ? out : keys;
|
|
618
662
|
}
|
|
619
663
|
/**
|
|
620
|
-
*
|
|
621
|
-
* (
|
|
664
|
+
* 压缩后的坐标 → 原文档坐标。**一条律**(S-323c 3.0.0 立,12.3.0 S-1494 推到每一层):坐标里的每个**数字段** `p[i]`,若它前面那段
|
|
665
|
+
* 前缀(已回写成原文档坐标、按 {@link formatConfigPath} 出门形)在位移表里 ⇔ 它是那个被压缩过的数组的下标 ⇒ 换成原下标。
|
|
666
|
+
* 从左往右逐段做:外层先回写,内层的前缀才是原文档坐标,才对得上表里的键(位移表的键都是原文档全路径)—— 两层都压缩过时
|
|
667
|
+
* 两层各回写一次,只回写最长前缀那一层会把外层的下标留在压缩坐标上。
|
|
622
668
|
*
|
|
623
669
|
* 2.1.0 在点号串上需要三道门(字段在表里 / 段数合它那一支文法 / 下标段是规范十进制),因为串把「这一段
|
|
624
670
|
* 是下标还是键名」这个位丢了,只能靠文法猜回来;段形把那个位带在类型里,三道门收成一道 —— 判它是正确
|
|
625
671
|
* 抽象的第一判据就是**规则数变少**(`PathGrammar`、段数门、规范十进制正则三件随之删除)。
|
|
626
672
|
*
|
|
627
|
-
* 字面含点的顶层键(`["models.1.k"]`)
|
|
628
|
-
* (`[field, key]`)同样:Record
|
|
629
|
-
* 是字符串键、不是下标 ——
|
|
673
|
+
* 字面含点的顶层键(`["models.1.k"]`)天然不受影响:它只有一段,没有数字段。Record 形的条目坐标
|
|
674
|
+
* (`[field, key]`)同样:Record 从不进位移表(条目按键寻址,丢一条不动其余键的坐标),而它的那一段
|
|
675
|
+
* 是字符串键、不是下标 —— 两道判据各自都挡得住。首段不判(域文档的根是对象,首段恒是字段名)。
|
|
630
676
|
*
|
|
631
677
|
* **恒等纪律**:没有任何一段真的变了(表长外、或原下标与压缩后下标相同)⇒ 返回**原引用**,不复制。
|
|
632
678
|
*/
|
|
633
679
|
function rebasePath(p, keptIndex) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
680
|
+
let out;
|
|
681
|
+
for (let i = 1; i < p.length; i++) {
|
|
682
|
+
const seg = p[i];
|
|
683
|
+
if (typeof seg !== "number")
|
|
684
|
+
continue;
|
|
685
|
+
const at = formatConfigPath((out ?? p).slice(0, i));
|
|
686
|
+
if (!Object.prototype.hasOwnProperty.call(keptIndex, at))
|
|
687
|
+
continue;
|
|
688
|
+
const original = keptIndex[at][seg];
|
|
689
|
+
if (original === undefined || original === seg)
|
|
690
|
+
continue; // 表长外 ⇒ 原样(与 2.1.0「越界原样」同律)
|
|
691
|
+
(out ??= [...p])[i] = original;
|
|
692
|
+
}
|
|
693
|
+
return out ?? p;
|
|
644
694
|
}
|
|
645
695
|
/** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
|
|
646
696
|
const DROP_SCAN_MAX_DEPTH = 8;
|
|
@@ -796,23 +846,90 @@ function carriedUnknownKeys(domain, parsed) {
|
|
|
796
846
|
return acc;
|
|
797
847
|
}
|
|
798
848
|
/**
|
|
799
|
-
*
|
|
800
|
-
*
|
|
849
|
+
* **文档面**的域读函数:一域的严 parse + **静默剥键/静默承运两条告警**,**永不抢救**。`FileConfigStore.getDomain`(于是 `exportBundle`、
|
|
850
|
+
* `createSiblingResolver`)与一致性门写入席(`strictWrite` 腿)读它 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
|
|
801
851
|
* (对抗复审 复审 round2 F3:导出一份被剥了键的域再导入 = 持久的配置丢失,而且全程无声)。
|
|
852
|
+
*
|
|
853
|
+
* 文档面回答「这份文档本身读不读得进」:读不进就抛(sanitized ZodError),一条条目都不替运维删 —— 写入席、导出、读改写的「现状」读的都是它。
|
|
854
|
+
* 视图面(「这份文档在生效配置里是什么」)是 {@link parseDomainSalvaged}:同一只内部体({@link readDomainDocument}),读不进时多一步逐条抢救。
|
|
802
855
|
*/
|
|
803
856
|
export function parseDomainLoud(domain, raw, onWarning) {
|
|
804
|
-
|
|
857
|
+
return readDomainDocument(domain, raw, onWarning, (unreadable) => {
|
|
858
|
+
throw unreadable;
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* **视图面**的域读函数(12.3.0,S-1494):与 {@link parseDomainLoud} 同一只内部体,整域读不进时按 {@link DOMAIN_ENTRY_SALVAGE} 逐条抢救。
|
|
863
|
+
* `buildEffective` 十六个域一处调它(`parseOr`);管理面要显示「生效配置里这个域是什么」时读它。
|
|
864
|
+
*
|
|
865
|
+
* 读法:
|
|
866
|
+
* 1. 整域 `parseDomain`;过 ⇒ 未知键两条告警按原文档坐标出门,返回 —— 与 {@link parseDomainLoud} 逐字同值同告警(好配置的路径一字不动)。
|
|
867
|
+
* 2. 不过,且 {@link DOMAIN_ENTRY_SALVAGE} 判该域 `"salvage"` ⇒ {@link salvageEntries}(递归到嵌套集合);丢了东西 ⇒ 拿压缩后的
|
|
868
|
+
* 文档再 `parseDomain` 一次。过 ⇒ 每条被丢条目一条 `entry-dropped`(它们本来就是原文档坐标),其余告警是对压缩文档的诊断,
|
|
869
|
+
* 过本层位移表({@link rebaseWarningPaths})回写成原文档坐标(坐标律 S-323b)。
|
|
870
|
+
* 3. 其余一切(`"none"` 档、什么都没丢、丢了仍读不进)⇒ **抛第 1 步的原错**,零告警 —— 一个域一个结论:没救活就不留「丢了某条」的半路叙述。
|
|
871
|
+
* 整域读不进之后是默认化还是抛,是调用方按 {@link DOMAIN_READ_FALLBACK} 的事(`buildEffective` 的 `parseOr`),本函数不读那张表。
|
|
872
|
+
*
|
|
873
|
+
* 写路径与导出面不调它:`parseDomain` / `validateDomain` / `writeDomainFile` 判整份文档,写入席与 `getDomain` / `exportBundle` 读 {@link parseDomainLoud}。
|
|
874
|
+
* 读改写(读存量、改、整份写回)的「现状」也不许读它 —— 抢救读出再写回,被丢的条目就成了删除。
|
|
875
|
+
*/
|
|
876
|
+
export function parseDomainSalvaged(domain, raw, onWarning) {
|
|
877
|
+
return readDomainDocument(domain, raw, onWarning, (unreadable) => parseSalvaged(domain, raw, unreadable, onWarning));
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* 两面共用的**唯一内部体**:严 parse(`parseDomain`)+ 未知键两条告警。整域读不进时交给 `whenUnreadable`(文档面:抛原错;视图面:逐条抢救)——
|
|
881
|
+
* 两个读函数只差这一个入参。告警在 parse 成功之后、`try` 之外发:`onWarning` 自己抛的错不会被当成「文档读不进」。
|
|
882
|
+
*/
|
|
883
|
+
function readDomainDocument(domain, raw, onWarning, whenUnreadable) {
|
|
884
|
+
let parsed;
|
|
885
|
+
try {
|
|
886
|
+
parsed = parseDomain(domain, raw);
|
|
887
|
+
}
|
|
888
|
+
catch (unreadable) {
|
|
889
|
+
return whenUnreadable(unreadable);
|
|
890
|
+
}
|
|
891
|
+
if (onWarning)
|
|
892
|
+
announceUnknownKeys(domain, raw, parsed, onWarning);
|
|
893
|
+
return parsed;
|
|
894
|
+
}
|
|
895
|
+
/** {@link parseDomainSalvaged} 的第 2 / 3 步(整域 parse 已经失败):能救就救,救不了就抛 `unreadable`(第 1 步的原错)。
|
|
896
|
+
* 档位判 `!== "salvage"`(不是 `=== "none"`):一个不是域的名字(JS 调用方 / 写入席收到的任意键)查表得到 `undefined` 或原型链上的东西,
|
|
897
|
+
* 照样不抢救 —— 只有表里白纸黑字的 `"salvage"` 才开这扇门。 */
|
|
898
|
+
function parseSalvaged(domain, raw, unreadable, onWarning) {
|
|
899
|
+
if (DOMAIN_ENTRY_SALVAGE[domain] !== "salvage")
|
|
900
|
+
throw unreadable;
|
|
901
|
+
const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
|
|
902
|
+
if (salvage.dropped.length === 0)
|
|
903
|
+
throw unreadable;
|
|
904
|
+
let parsed;
|
|
905
|
+
try {
|
|
906
|
+
parsed = parseDomain(domain, salvage.raw);
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
throw unreadable; // 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它
|
|
910
|
+
}
|
|
805
911
|
if (onWarning) {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
onWarning({ domain, kind: "unknown-keys-carried", keys: carried.keys, ...(carried.total > carried.keys.length ? { truncatedFrom: carried.total } : {}) });
|
|
912
|
+
// 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
|
|
913
|
+
// `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;未知键扫描是对本层**压缩后**文档的诊断 ⇒ 过本层的位移表。
|
|
914
|
+
for (const d of salvage.dropped)
|
|
915
|
+
onWarning(entryDroppedWarning(domain, d));
|
|
916
|
+
announceUnknownKeys(domain, salvage.raw, parsed, (w) => onWarning(rebaseWarningPaths(w, salvage.keptIndex)));
|
|
812
917
|
}
|
|
813
918
|
return parsed;
|
|
814
919
|
}
|
|
920
|
+
/** 未知键两条告警(剥掉的 / 承运的)—— `raw` 是这次真被 parse 的那份文档,`emit` 决定坐标出门前要不要回写。 */
|
|
921
|
+
function announceUnknownKeys(domain, raw, parsed, emit) {
|
|
922
|
+
const dropped = droppedKeys(domain, raw, parsed);
|
|
923
|
+
if (dropped.keys.length > 0)
|
|
924
|
+
emit({ domain, kind: "unknown-keys-dropped", keys: dropped.keys, ...(dropped.total > dropped.keys.length ? { truncatedFrom: dropped.total } : {}) });
|
|
925
|
+
const carried = carriedUnknownKeys(domain, parsed);
|
|
926
|
+
if (carried.keys.length > 0)
|
|
927
|
+
emit({ domain, kind: "unknown-keys-carried", keys: carried.keys, ...(carried.total > carried.keys.length ? { truncatedFrom: carried.total } : {}) });
|
|
928
|
+
}
|
|
815
929
|
/** Per-domain READ-corruption policy (0.8.1) — what buildEffective does when a stored domain no longer parses.
|
|
930
|
+
* **One question only** (12.3.0, S-1494): the domain as a whole is unreadable — after {@link parseDomainSalvaged} has tried
|
|
931
|
+
* whatever entry salvage {@link DOMAIN_ENTRY_SALVAGE} allows — so does the read default it or throw? Whether single bad
|
|
932
|
+
* entries may be dropped is the OTHER table's question; neither table reads the other.
|
|
816
933
|
* `"default"` = degrade to the schema default + warn (safe: the default only REMOVES capability, fail-closed).
|
|
817
934
|
* `"throw"` = keep fail-loud (defaulting would LOOSEN gates or destructively apply — worse than an outage,
|
|
818
935
|
* consumers fail-static on last-good). A `satisfies Record<DomainName,…>` forces every FUTURE domain to make
|
|
@@ -861,6 +978,55 @@ export const DOMAIN_READ_FALLBACK = {
|
|
|
861
978
|
// 消费方 fail-static 在上一份好配置严格更好。
|
|
862
979
|
limits: "throw",
|
|
863
980
|
};
|
|
981
|
+
/** Per-domain ENTRY-SALVAGE policy (12.3.0, S-1494) — may the view-face domain reader ({@link parseDomainSalvaged}) drop single bad
|
|
982
|
+
* entries (an array element, a Record value, and — recursively — a bad entry inside a surviving entry) and read the rest?
|
|
983
|
+
* **One question only**: what happens when the domain stays unreadable after that is {@link DOMAIN_READ_FALLBACK}'s question.
|
|
984
|
+
* `"salvage"` = drop each bad entry, name it (`entry-dropped`, coordinate + identity + reason word), keep the rest.
|
|
985
|
+
* `"none"` = never drop a single entry: the first bad entry makes the whole domain unreadable.
|
|
986
|
+
* A `satisfies Record<DomainName,…>` forces every FUTURE domain to answer this explicitly (compile error until classified).
|
|
987
|
+
*
|
|
988
|
+
* **View face only.** The document face never salvages: `parseDomain` / `validateDomain` / `writeDomainFile` judge the whole
|
|
989
|
+
* document, and {@link parseDomainLoud} — what the conformance write seat (`strictWrite` leg), `FileConfigStore.getDomain` and
|
|
990
|
+
* `exportBundle` read — throws on it. A write path that reads the stored "current" value through `parseDomainSalvaged` and writes
|
|
991
|
+
* it back would turn every dropped entry into a deletion — read-modify-write faces read the stored document with the document face.
|
|
992
|
+
*
|
|
993
|
+
* Per-domain rationale:
|
|
994
|
+
* - models/skills/mcp/plugins/scenarios/systems/collab/prompts: additive capability / content catalogs — a dropped entry is
|
|
995
|
+
* one capability gone (fail-closed). Inside a surviving entry the same holds: the nested collections these entries carry
|
|
996
|
+
* are allowlists / scopings (`allowTools`, `scenarios`, `rosters`, `callsWorkers`, …) where one entry fewer only NARROWS,
|
|
997
|
+
* and the zero-survivor law (never mint an empty collection — several of these read "empty = all") holds at every level.
|
|
998
|
+
* - hosts: a dropped host is one placement target fewer; a dropped label makes a host match fewer `placement.requires`.
|
|
999
|
+
* - projects: SALVAGE — a registration absent from the ledger = that project's claim is refused (fail-closed). Throwing the
|
|
1000
|
+
* whole domain stops EVERY project of the scope, which protects nothing: one stored registration that a later, tighter
|
|
1001
|
+
* schema refuses (12.2.1's UNC judgement on `contextItems[].value`) must not take the scope's other projects with it.
|
|
1002
|
+
* Its whenUnreadable stance stays `"throw"` (a ledger that cannot be read at all is never defaulted — see
|
|
1003
|
+
* {@link DOMAIN_READ_FALLBACK}).
|
|
1004
|
+
* - rosters: NONE — a dropped roster makes resolveEffectiveForWorker fall back to the FULL models catalog for that worker
|
|
1005
|
+
* (放权,不许).
|
|
1006
|
+
* - workers: NONE — a dropped worker row reads as "this worker is no longer declared": the reconciler would retire it. The
|
|
1007
|
+
* per-row tolerance this domain does have lives in `readWorkersRows` (rows are HELD with their original, never dropped).
|
|
1008
|
+
* - governance: NONE — a dropped commandPolicy / approvalRequire row is a guardrail silently gone (放权,不许).
|
|
1009
|
+
* - entitlement: NONE — a dropped tier / subject / binding / budget group changes who may do what (放权,不许).
|
|
1010
|
+
* - execution: NONE — a dropped lane / public key changes the execution mandate (放权,不许).
|
|
1011
|
+
* - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings). */
|
|
1012
|
+
export const DOMAIN_ENTRY_SALVAGE = {
|
|
1013
|
+
models: "salvage",
|
|
1014
|
+
rosters: "none",
|
|
1015
|
+
skills: "salvage",
|
|
1016
|
+
mcp: "salvage",
|
|
1017
|
+
plugins: "salvage",
|
|
1018
|
+
scenarios: "salvage",
|
|
1019
|
+
systems: "salvage",
|
|
1020
|
+
collab: "salvage",
|
|
1021
|
+
workers: "none",
|
|
1022
|
+
hosts: "salvage",
|
|
1023
|
+
governance: "none",
|
|
1024
|
+
entitlement: "none",
|
|
1025
|
+
execution: "none",
|
|
1026
|
+
projects: "salvage",
|
|
1027
|
+
prompts: "salvage",
|
|
1028
|
+
limits: "none",
|
|
1029
|
+
};
|
|
864
1030
|
/**
|
|
865
1031
|
* Merge every domain into the effective config a consumer pulls. Shared by all store impls.
|
|
866
1032
|
* `updatedAt` is REQUIRED (the determinism refactor — the original store.ts defaulted it to the wall clock,
|
|
@@ -873,41 +1039,17 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
873
1039
|
// 墓碑先判:一份没迁的retired 域文档意味着这个 scope 的存量还没过一次性迁移 —— 拿剩下的域拼一份 effective 等于静默丢掉它的事实。
|
|
874
1040
|
for (const retired of Object.keys(RETIRED_DOMAINS))
|
|
875
1041
|
refuseRetiredDomain(retired, getRaw(retired));
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
// Generic READ guardrail (0.8.1): a domain that no longer parses degrades to its schema default + a warning
|
|
881
|
-
// — but ONLY where DOMAIN_READ_FALLBACK says the default is safe; gate-bearing domains keep throwing (决策
|
|
882
|
-
// 表与逐域理由见 DOMAIN_READ_FALLBACK). The success path is UNCHANGED (same parseDomain, same bytes).
|
|
1042
|
+
// 每个域同一只读法(12.3.0,S-1494):视图面读函数 {@link parseDomainSalvaged}(整域 parse → 按 DOMAIN_ENTRY_SALVAGE 逐条抢救 → 未知键告警;
|
|
1043
|
+
// 与文档面 `parseDomainLoud` 共用同一只内部体)读不进 ⇒ 只剩**整域读不进**这一个问题,答案只看 DOMAIN_READ_FALLBACK(决策表与逐域理由见那张表):
|
|
1044
|
+
// `"throw"` ⇒ 原错抛出;`"default"` ⇒ schema 默认 + `domain-defaulted`。哪个域走哪一档不再写在下面的行上(此前 gate 域的行
|
|
1045
|
+
// 调 parseLoud、其余调 parseOr —— 同一张表的第二份手抄),行只说「读这个域」。好配置的路径一字不动(同一次 parseDomain,同一份字节)。
|
|
883
1046
|
const parseOr = (domain, raw) => {
|
|
884
1047
|
try {
|
|
885
|
-
return
|
|
1048
|
+
return parseDomainSalvaged(domain, raw, onWarning);
|
|
886
1049
|
}
|
|
887
1050
|
catch (e) {
|
|
888
1051
|
if (DOMAIN_READ_FALLBACK[domain] === "throw")
|
|
889
1052
|
throw e;
|
|
890
|
-
// 逐条容错(2.0.0)在整域降级**之前**试一次:只丢掉真正坏掉的那几条条目,同域好兄弟活下来。
|
|
891
|
-
// 告警先缓存再决定 —— 一个域只给一个结论:救活了就逐条报 `entry-dropped`,没救活就只报
|
|
892
|
-
// `domain-defaulted`(那一域的条目反正一条都不剩,再讲「丢了第 3 条」只会误导)。
|
|
893
|
-
const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
|
|
894
|
-
if (salvage.dropped.length > 0) {
|
|
895
|
-
try {
|
|
896
|
-
const buffered = [];
|
|
897
|
-
const parsed = parseDomainLoud(domain, salvage.raw, (w) => buffered.push(w));
|
|
898
|
-
// 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
|
|
899
|
-
// `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;`buffered` 里的是对本层**压缩后**
|
|
900
|
-
// 文档的诊断 ⇒ 先过本层的位移表。读路径上只有这一个压缩点(11.0.0 起 hosts 的读侧夹紧臂已retired)。
|
|
901
|
-
for (const d of salvage.dropped)
|
|
902
|
-
onWarning?.(entryDroppedWarning(domain, d));
|
|
903
|
-
for (const w of buffered)
|
|
904
|
-
onWarning?.(rebaseWarningPaths(w, salvage.keptIndex));
|
|
905
|
-
return parsed;
|
|
906
|
-
}
|
|
907
|
-
catch {
|
|
908
|
-
// 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它,照常整域降级。
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
1053
|
onWarning?.({ domain, kind: "domain-defaulted", error: e });
|
|
912
1054
|
return parseDomain(domain, undefined); // schema default — always valid
|
|
913
1055
|
}
|
|
@@ -916,30 +1058,26 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
916
1058
|
version,
|
|
917
1059
|
updatedAt,
|
|
918
1060
|
models: parseOr("models", getRaw("models")),
|
|
919
|
-
rosters:
|
|
1061
|
+
rosters: parseOr("rosters", getRaw("rosters")),
|
|
920
1062
|
skills: parseOr("skills", getRaw("skills")),
|
|
921
1063
|
mcp: parseOr("mcp", getRaw("mcp")),
|
|
922
1064
|
plugins: parseOr("plugins", getRaw("plugins")),
|
|
923
1065
|
scenarios: parseOr("scenarios", getRaw("scenarios")),
|
|
924
1066
|
systems: parseOr("systems", getRaw("systems")),
|
|
925
1067
|
collab: parseOr("collab", getRaw("collab")),
|
|
926
|
-
workers:
|
|
1068
|
+
workers: parseOr("workers", getRaw("workers")),
|
|
927
1069
|
hosts: parseOr("hosts", getRaw("hosts")),
|
|
928
1070
|
// governance TRUTH = the domain itself (11.0.0: the read-side lift from a 0.5-era runtime document is gone — the
|
|
929
1071
|
// runtime tombstone above refuses such a document, and the one-time migration SS-M1 moves its trio here).
|
|
930
|
-
governance:
|
|
931
|
-
entitlement:
|
|
1072
|
+
governance: parseOr("governance", getRaw("governance")),
|
|
1073
|
+
entitlement: parseOr("entitlement", getRaw("entitlement")),
|
|
932
1074
|
// execution (0.8.0, S3): plain scope-level parse — MIRRORS governance (no overlay/merge invented here).
|
|
933
|
-
execution:
|
|
934
|
-
|
|
935
|
-
// (
|
|
936
|
-
//
|
|
937
|
-
projects: parseLoud("projects", getRaw("projects")),
|
|
938
|
-
// prompts (0.10.18, [1064]②): content domain → parseOr(坏形回空默认=「无 prompts 管理」语义;
|
|
939
|
-
// fail-closed 由 PUBLISH_MODE 发布闸承担,空 bindings 不下发内容,default 不扩权)。
|
|
1075
|
+
execution: parseOr("execution", getRaw("execution")),
|
|
1076
|
+
projects: parseOr("projects", getRaw("projects")),
|
|
1077
|
+
// prompts (0.10.18, [1064]②): content domain(坏形回空默认=「无 prompts 管理」语义;fail-closed 由 PUBLISH_MODE 发布闸承担,
|
|
1078
|
+
// 空 bindings 不下发内容,default 不扩权)。
|
|
940
1079
|
prompts: parseOr("prompts", getRaw("prompts")),
|
|
941
|
-
|
|
942
|
-
limits: parseLoud("limits", getRaw("limits")),
|
|
1080
|
+
limits: parseOr("limits", getRaw("limits")),
|
|
943
1081
|
};
|
|
944
1082
|
}
|
|
945
1083
|
/** READ-side tolerant judgment for a GET /effective **wire payload**(the {@link EffectiveWire} contract)at
|