@aalis/schema-config 0.15.0 → 0.16.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/README.md +30 -8
- package/dist/index.d.ts +140 -32
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +173 -83
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +42 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +42 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +83 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +45 -0
- package/dist/texts.js.map +1 -0
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// ConfigOf 从同一份 schema 推导配置值类型;
|
|
6
6
|
// 函数部分是对这套词汇的**中立解释**:默认值派生(defaultsFrom)与深合并(deepMergeDefaults)、
|
|
7
7
|
// 只读结构校验(validateConfig)、按 schema 键集裁未知字段(removeExtraFields)、
|
|
8
|
-
// 按 schema 解析插件拿到的配置(parseConfig)。
|
|
8
|
+
// 按 schema 解析插件拿到的配置(parseConfig)与单个取值的判定(readFieldValue)。
|
|
9
9
|
// 它们都不属于 @aalis/core——core 只把 `PluginMeta.configSchema` 当作
|
|
10
10
|
// opaque 数据透传,不解释任何字段。另有插件因配置问题无法激活时抛的错误(configError / missingConfigError)。
|
|
11
11
|
//
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
// - 渲染宿主(webui-server/client 等):读取并渲染表单
|
|
15
15
|
// - 宿主政策(@aalis/runtime 的 config-sync):按 schema 裁剪未知字段、校验告警
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// 给人看的文字(表单的 label 与说明、校验原因、配置错误)是 @aalis/schema-i18n 的文字对象,由出口按读者语言渲染。
|
|
18
|
+
// 运行时只依赖同样零依赖的 @aalis/schema-i18n;对 @aalis/core 仅有 type-only 锚点 import(编译后擦除)。
|
|
18
19
|
// ============================================================
|
|
20
|
+
import { isTextRef, withText } from '@aalis/schema-i18n';
|
|
21
|
+
import { t } from './texts.js';
|
|
19
22
|
/** 是不是密钥引用:只有 `$secret` 一个自有键、值为非空字符串的纯对象 */
|
|
20
23
|
export function isSecretRef(value) {
|
|
21
24
|
if (!isRecord(value))
|
|
@@ -68,6 +71,13 @@ export function mapSecretFields(schema, config, fn) {
|
|
|
68
71
|
};
|
|
69
72
|
return visit(isRecord(schema) ? schema : {}, config, '', false);
|
|
70
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 顶层条目是不是可按会话覆盖的字段:是字段(不是分组、数组)且 session 不为 undefined。只对 configSchema 的顶层条目问,
|
|
76
|
+
* 分组与数组元素里的字段不按它判
|
|
77
|
+
*/
|
|
78
|
+
export function isSessionField(entry) {
|
|
79
|
+
return isRecord(entry) && !('fields' in entry) && entry.type !== 'array' && entry.session !== undefined;
|
|
80
|
+
}
|
|
71
81
|
/**
|
|
72
82
|
* 声明插件的配置 schema:运行时原样返回传入的对象(不冻结、不加任何属性),类型层保留字面量,
|
|
73
83
|
* 供 {@link ConfigOf} 推出配置值类型。返回值可直接放进 `definePlugin({ configSchema })`。
|
|
@@ -88,14 +98,14 @@ export function defineConfig(schema) {
|
|
|
88
98
|
export const CORE_CONFIG_SCHEMA = {
|
|
89
99
|
name: {
|
|
90
100
|
type: 'string',
|
|
91
|
-
label: '
|
|
92
|
-
description: '
|
|
101
|
+
label: t('nameLabel'),
|
|
102
|
+
description: t('nameDescription'),
|
|
93
103
|
default: 'Aalis',
|
|
94
104
|
},
|
|
95
105
|
logLevel: {
|
|
96
106
|
type: 'select',
|
|
97
|
-
label: '
|
|
98
|
-
description: '
|
|
107
|
+
label: t('logLevelLabel'),
|
|
108
|
+
description: t('logLevelDescription'),
|
|
99
109
|
default: 'info',
|
|
100
110
|
options: [
|
|
101
111
|
{ label: 'debug', value: 'debug' },
|
|
@@ -106,12 +116,17 @@ export const CORE_CONFIG_SCHEMA = {
|
|
|
106
116
|
},
|
|
107
117
|
slowThresholdMs: {
|
|
108
118
|
type: 'number',
|
|
109
|
-
label: '
|
|
110
|
-
description: '
|
|
119
|
+
label: t('slowThresholdLabel'),
|
|
120
|
+
description: t('slowThresholdDescription'),
|
|
111
121
|
default: 60000,
|
|
112
122
|
min: 0,
|
|
113
123
|
},
|
|
114
124
|
};
|
|
125
|
+
/**
|
|
126
|
+
* 宿主配置里只在启动时读、改了要重启才生效的核心键:runtime 启动时读它们交给 core,WebUI 设置页据此标「重启后生效」。
|
|
127
|
+
* runtime 改了哪些键只在启动时读,同步改这一份。name 只是显示用的应用名称,保存即生效
|
|
128
|
+
*/
|
|
129
|
+
export const CORE_RESTART_KEYS = ['logLevel', 'slowThresholdMs'];
|
|
115
130
|
/**
|
|
116
131
|
* 从 ConfigSchema 派生默认配置(密钥字段除外)。
|
|
117
132
|
*
|
|
@@ -167,8 +182,8 @@ export function deepMergeDefaults(defaults, current) {
|
|
|
167
182
|
return result;
|
|
168
183
|
}
|
|
169
184
|
// 校验与解析在运行时需要一份"本包内置类型"名单:SchemaFieldTypes 是纯类型层的
|
|
170
|
-
// merging 注册表,运行时不存在。外来类型(如 'llm-ref'
|
|
171
|
-
// validateConfig
|
|
185
|
+
// merging 注册表,运行时不存在。外来类型(如 'llm-ref')落在名单之外,不经类型判定、只过字段自己的 validate:
|
|
186
|
+
// validateConfig 放行、parseConfig 原样透传——开放词汇表要求两者同样开放。
|
|
172
187
|
const NEUTRAL_FIELD_TYPES = new Set([
|
|
173
188
|
'string',
|
|
174
189
|
'number',
|
|
@@ -179,7 +194,10 @@ const NEUTRAL_FIELD_TYPES = new Set([
|
|
|
179
194
|
'list',
|
|
180
195
|
'map',
|
|
181
196
|
]);
|
|
182
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* 字段类型是不是本包内置的:内置类型的取值经 validateConfig 与 parseConfig 按类型校验,外来类型(如 'llm-ref')的
|
|
199
|
+
* 只过字段的 validate
|
|
200
|
+
*/
|
|
183
201
|
export function isBuiltinFieldType(type) {
|
|
184
202
|
return NEUTRAL_FIELD_TYPES.has(type);
|
|
185
203
|
}
|
|
@@ -188,10 +206,11 @@ export function isBuiltinFieldType(type) {
|
|
|
188
206
|
*
|
|
189
207
|
* 定位与边界:
|
|
190
208
|
* - **只读**:不转换、不回填、不裁剪——回填是 defaultsFrom,裁剪是 removeExtraFields,取值是 parseConfig。
|
|
191
|
-
* 逐字段判定与 parseConfig 共用同一份:这里报 invalid 的值,parseConfig
|
|
209
|
+
* 逐字段判定与 parseConfig 共用同一份:这里报 invalid 的值,parseConfig 会回落、忽略或拒绝(带 session 的顶层字段除外,
|
|
210
|
+
* parseConfig 不交出它)。
|
|
192
211
|
* 校验器出错的最大代价必须始终是"少一条警告",因此它对畸形 schema 不抛错。
|
|
193
|
-
* - **只解释本包的词汇**:type/required、约束键、影响取值的 dynamicOptions/allowCustom
|
|
194
|
-
* 宿主经 declaration merging
|
|
212
|
+
* - **只解释本包的词汇**:type/required、约束键、影响取值的 dynamicOptions/allowCustom、secret 与字段自己的 validate;
|
|
213
|
+
* 宿主经 declaration merging 注入的呈现属性一概不解读。带 session 的字段照常校验(它的全局层就在配置文档里)。
|
|
195
214
|
* - **校验的是文档**:`secret` 字段上的 {@link SecretRef} 算合法(值在宿主的密钥存储里),不查引用的键在不在。
|
|
196
215
|
* - **标量宽容**:期望字符串的位置(string/textarea、list 元素、map 值、选项比较)接受有限数字;
|
|
197
216
|
* 期望数字的位置接受去掉首尾空白后能完整解析为有限数的字符串。boolean 不做转换。
|
|
@@ -200,6 +219,8 @@ export function isBuiltinFieldType(type) {
|
|
|
200
219
|
* - **约束键**:number 强制 min/max(含边界)与 integer,string/textarea 强制
|
|
201
220
|
* pattern(模式非法则跳过该检查)与 format(空串与只有空白不查);step 是纯 UI 提示不校验。约束只在类型
|
|
202
221
|
* 检查通过后评估——类型都不对时报类型错,不叠报约束错。
|
|
222
|
+
* - **validate**:类型与约束键都过了才调,拿换算后的取值;返回原因报 invalid(code 为 `validate`),抛错同样报,不外传。
|
|
223
|
+
* list、map、multiselect 有坏元素时只报元素、不调它:它拿到的会是剔掉坏元素的值,报的原因对不上文档里写的。
|
|
203
224
|
* - **list / map**:list 须为数组、map 须为对象,逐元素 / 逐值报错,路径形如 `args[1]`、`env.KEY`。
|
|
204
225
|
* - **宽容缺失**:undefined 与 null(YAML 裸键)视为"未配置",required 字段的 `''` 也算未配置(WebUI 与脚手架
|
|
205
226
|
* 用 `''` 表示未填,与 parseConfig 一致;声明了 format 的只有空白也算;空数组仍算已配置),仅在 required 且未声明 default 时报缺——顶层/分组的默认值在调用点已合并,数组元素的默认值
|
|
@@ -219,10 +240,12 @@ export function validateConfig(schema, config) {
|
|
|
219
240
|
*
|
|
220
241
|
* - **缺失**:undefined 与 null;required 字段另把 `''` 当作缺失(WebUI 与脚手架用 `''` 表示未填;声明了 format 的
|
|
221
242
|
* 只有空白也算),外来类型的 `''` 一律当作缺失。有 default 用 default(拷贝),无 default 且 required 不可恢复,否则省略该键。
|
|
222
|
-
* - **无效**:逐字段判定与 validateConfig
|
|
243
|
+
* - **无效**:逐字段判定与 validateConfig 共用(标量宽容、选项即取值范围、约束键、字段的 validate)。
|
|
244
|
+
* list、map、multiselect 先丢坏元素,validate 判的是剩下的值。
|
|
223
245
|
* 默认有 default 回落并告警;无 default 且 required 不可恢复;否则省略并告警。
|
|
224
246
|
* `onInvalid: 'error'` 的字段遇到显式无效值或坏集合成员时,即使有 default 也不可恢复。
|
|
225
|
-
* - **外来类型**:除 `''`
|
|
247
|
+
* - **外来类型**:除 `''` 外原样透传,只过字段的 validate。
|
|
248
|
+
* - **带 session 的顶层字段**:不交出、不补 default,配置里有这个键也不告警(插件经会话设置的读取器读)。
|
|
226
249
|
* - **密钥字段**:宿主交给插件之前已把引用解析成原值;`secret` 字段上仍是 {@link SecretRef},说明引用的键不在
|
|
227
250
|
* 宿主的密钥存储里,一律不可恢复(不回落 default,数组元素里的也不只丢这一条),消息点名键名。owner 写了引用,
|
|
228
251
|
* 引用落空就是配置错误,回落默认值会悄悄连上别的东西(例如默认的本机数据库)。
|
|
@@ -233,7 +256,7 @@ export function validateConfig(schema, config) {
|
|
|
233
256
|
* - **schema 外的键**:丢弃并告警。
|
|
234
257
|
* - **不可恢复**(顶层或分组内):缺失抛 {@link missingConfigError},无效抛 {@link configError},插件随之进 error 态。
|
|
235
258
|
*
|
|
236
|
-
*
|
|
259
|
+
* 告警只写路径、类型名与约束,不写原值(可能是密钥);默认值可以写。字段 validate 返回的原因原样写进去。
|
|
237
260
|
*/
|
|
238
261
|
export function parseConfig(schema, raw, logger) {
|
|
239
262
|
const warn = (message) => logger?.warn(message);
|
|
@@ -241,8 +264,8 @@ export function parseConfig(schema, raw, logger) {
|
|
|
241
264
|
if (isRecord(raw))
|
|
242
265
|
source = raw;
|
|
243
266
|
else if (raw !== undefined && raw !== null) {
|
|
244
|
-
if (hasStrictField(schema))
|
|
245
|
-
throw configError('
|
|
267
|
+
if (hasStrictField(schema, true))
|
|
268
|
+
throw configError(t('notObject'));
|
|
246
269
|
warn(`配置整体期望对象,得到 ${describeType(raw)},已忽略`);
|
|
247
270
|
}
|
|
248
271
|
try {
|
|
@@ -253,9 +276,27 @@ export function parseConfig(schema, raw, logger) {
|
|
|
253
276
|
throw err;
|
|
254
277
|
throw err.missing
|
|
255
278
|
? missingConfigError(err.path)
|
|
256
|
-
: configError(
|
|
279
|
+
: configError(t('invalid', { path: err.path, reason: err.reason }));
|
|
257
280
|
}
|
|
258
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* 单个字段对一个值的判定,与 validateConfig 同一套(类型、约束键、字段的 validate):合法给出换算后的取值,没配置的
|
|
284
|
+
* (undefined、null,外来类型与 required 字段的空串)给 `{ value: undefined }`;不合法给出原因,list、map、multiselect
|
|
285
|
+
* 里有元素不合法也算(取第一个)。不回落 default、不抛错,不拷贝(外来类型的值原样交出)。判的是取值本身:密钥字段上的
|
|
286
|
+
* 引用不像 validateConfig 那样按文档的写法放行
|
|
287
|
+
*/
|
|
288
|
+
export function readFieldValue(field, value) {
|
|
289
|
+
if (isUnset(field, value))
|
|
290
|
+
return { value: undefined };
|
|
291
|
+
const verdict = judgeField(field, value);
|
|
292
|
+
if ('invalid' in verdict)
|
|
293
|
+
return { invalid: verdict.invalid.text };
|
|
294
|
+
const [dropped] = verdict.dropped;
|
|
295
|
+
if (dropped)
|
|
296
|
+
return { invalid: dropped.reason.text };
|
|
297
|
+
const invalid = runValidate(field, verdict.value);
|
|
298
|
+
return invalid ? { invalid: invalid.text } : { value: verdict.value };
|
|
299
|
+
}
|
|
259
300
|
/**
|
|
260
301
|
* 按 schema 键集裁掉未知字段。configSchema 是插件配置的**唯一声明来源**(默认值也从它派生),
|
|
261
302
|
* 所以它的键集就是完整的白名单:不在 schema 里的字段,要么是用户手写的错别字,
|
|
@@ -294,22 +335,24 @@ export function removeExtraFields(config, schema, removed, prefix = '') {
|
|
|
294
335
|
*
|
|
295
336
|
* 实例照常转入 error 态,但这不是程序出错:core 的 DefaultLogger 对没有 stack 的错误只写「名称: 消息」,
|
|
296
337
|
* 激活失败日志因此只有一行,不像程序崩溃。缺必填项用 {@link missingConfigError}。
|
|
338
|
+
* 收文字对象时挂上它(schema-i18n 的 withText,name 不变),出口按读者语言显示,message 是它的原文。
|
|
297
339
|
*/
|
|
298
340
|
export function configError(message) {
|
|
299
|
-
const error = new Error(message);
|
|
341
|
+
const error = new Error(typeof message === 'string' ? message : message.source);
|
|
300
342
|
error.name = 'ConfigError';
|
|
301
343
|
// 定义成自有属性而不是 delete:有的引擎把 stack 做成原型上的访问器,删实例属性去不掉
|
|
302
344
|
Object.defineProperty(error, 'stack', { value: undefined, writable: true, configurable: true });
|
|
303
|
-
return error;
|
|
345
|
+
return typeof message === 'string' ? error : withText(error, message);
|
|
304
346
|
}
|
|
305
347
|
/**
|
|
306
348
|
* 缺少必填配置时的 {@link configError}:消息点名缺的字段(`note` 是放在字段名后括号里的补充说明),并说明
|
|
307
349
|
* 填入后生效——WebUI 保存或配置文件热重载会重建实例、重试激活。
|
|
308
350
|
*/
|
|
309
351
|
export function missingConfigError(field, note) {
|
|
310
|
-
return configError(
|
|
352
|
+
return configError(note ? t('missingFieldNote', { field, note }) : t('missingField', { field }));
|
|
311
353
|
}
|
|
312
354
|
function validateFields(schema, config, prefix, issues) {
|
|
355
|
+
const push = (path, kind, { code, text }) => issues.push({ path, message: text.source, kind, code, text });
|
|
313
356
|
// `?? {}` 与 defaultsFrom 同款兜底:畸形 schema(array 缺 items / fields 为 null)
|
|
314
357
|
// 不许把"最多少一条警告"升级成 TypeError——校验器自身永远不能成为故障源。
|
|
315
358
|
for (const [key, entry] of Object.entries(schema ?? {})) {
|
|
@@ -321,7 +364,7 @@ function validateFields(schema, config, prefix, issues) {
|
|
|
321
364
|
if (value === undefined || value === null)
|
|
322
365
|
continue;
|
|
323
366
|
if (!isRecord(value)) {
|
|
324
|
-
|
|
367
|
+
push(path, 'invalid', mismatch('expectedGroup', value));
|
|
325
368
|
continue;
|
|
326
369
|
}
|
|
327
370
|
validateFields(entry.fields, value, `${path}.`, issues);
|
|
@@ -331,79 +374,84 @@ function validateFields(schema, config, prefix, issues) {
|
|
|
331
374
|
if (value === undefined || value === null)
|
|
332
375
|
continue;
|
|
333
376
|
if (!Array.isArray(value)) {
|
|
334
|
-
|
|
377
|
+
push(path, 'invalid', mismatch('expectedArray', value));
|
|
335
378
|
continue;
|
|
336
379
|
}
|
|
337
380
|
value.forEach((item, i) => {
|
|
338
381
|
if (!isRecord(item)) {
|
|
339
|
-
|
|
382
|
+
push(`${path}[${i}]`, 'invalid', mismatch('expectedItem', item));
|
|
340
383
|
return;
|
|
341
384
|
}
|
|
342
385
|
validateFields(entry.items, item, `${path}[${i}].`, issues);
|
|
343
386
|
});
|
|
344
387
|
continue;
|
|
345
388
|
}
|
|
346
|
-
if (
|
|
389
|
+
if (isUnset(entry, value)) {
|
|
347
390
|
// 声明了 default 的字段永远不算缺失:顶层/分组的默认值多已在调用点合并,但合并不覆盖
|
|
348
391
|
// 显式 null(YAML 裸键),这类值照样走到这里,
|
|
349
392
|
// 数组元素的默认值不参与任何合并(defaultsFrom 把 SchemaArray 当叶子),
|
|
350
393
|
// 全靠这里放行——否则 required+default 的 item 字段会误报(scheduler jobs[].platform 型)。
|
|
351
394
|
if (entry.required && !('default' in entry))
|
|
352
|
-
|
|
395
|
+
push(path, 'missing', { code: 'missing', text: t('missing') });
|
|
353
396
|
continue;
|
|
354
397
|
}
|
|
355
398
|
if (entry.secret === true && isSecretRef(value))
|
|
356
399
|
continue;
|
|
357
|
-
if (!NEUTRAL_FIELD_TYPES.has(entry.type))
|
|
358
|
-
continue;
|
|
359
400
|
const verdict = judgeField(entry, value);
|
|
360
401
|
if ('invalid' in verdict) {
|
|
361
|
-
|
|
402
|
+
push(path, 'invalid', verdict.invalid);
|
|
362
403
|
continue;
|
|
363
404
|
}
|
|
364
405
|
for (const d of verdict.dropped)
|
|
365
|
-
|
|
406
|
+
push(path + d.at, 'invalid', d.reason);
|
|
407
|
+
const invalid = verdict.dropped.length > 0 ? undefined : runValidate(entry, verdict.value);
|
|
408
|
+
if (invalid)
|
|
409
|
+
push(path, 'invalid', invalid);
|
|
366
410
|
}
|
|
367
411
|
}
|
|
412
|
+
function mismatch(expected, value) {
|
|
413
|
+
return { code: 'type', text: t(expected, { got: describeType(value) }) };
|
|
414
|
+
}
|
|
415
|
+
/** 按类型与约束键判,外来类型不判;字段自己的 validate 由调用方处置完 dropped 之后调(runValidate) */
|
|
368
416
|
function judgeField(field, value) {
|
|
369
417
|
switch (field.type) {
|
|
370
418
|
case 'string':
|
|
371
419
|
case 'textarea': {
|
|
372
420
|
const text = asText(value);
|
|
373
421
|
if (text === undefined)
|
|
374
|
-
return { invalid:
|
|
422
|
+
return { invalid: mismatch('expectedString', value) };
|
|
375
423
|
if (field.pattern !== undefined && !matchesPattern(field.pattern, text)) {
|
|
376
|
-
return { invalid:
|
|
424
|
+
return { invalid: { code: 'pattern', text: t('pattern', { pattern: field.pattern }) } };
|
|
377
425
|
}
|
|
378
426
|
if (field.format === undefined)
|
|
379
427
|
return { value: text, dropped: [] };
|
|
380
428
|
// 路径按去掉首尾空白后判定,交出去的也是这一份:带空白的原值交给插件会被当成相对路径或找不到
|
|
381
429
|
const path = text.trim();
|
|
382
430
|
if (path !== '' && !matchesFormat(field.format, path)) {
|
|
383
|
-
return { invalid: field.format === 'executable' ? '
|
|
431
|
+
return { invalid: { code: 'format', text: t(field.format === 'executable' ? 'executable' : 'absolutePath') } };
|
|
384
432
|
}
|
|
385
433
|
return { value: path, dropped: [] };
|
|
386
434
|
}
|
|
387
435
|
case 'number': {
|
|
388
436
|
const num = asNumber(value);
|
|
389
437
|
if (num === undefined)
|
|
390
|
-
return { invalid:
|
|
438
|
+
return { invalid: mismatch('expectedNumber', value) };
|
|
391
439
|
if (field.integer && !Number.isInteger(num))
|
|
392
|
-
return { invalid: '
|
|
393
|
-
if (field.min !== undefined && num < field.min)
|
|
394
|
-
return { invalid:
|
|
395
|
-
|
|
396
|
-
|
|
440
|
+
return { invalid: { code: 'integer', text: t('integer') } };
|
|
441
|
+
if (field.min !== undefined && num < field.min) {
|
|
442
|
+
return { invalid: { code: 'min', text: t('min', { min: field.min }) } };
|
|
443
|
+
}
|
|
444
|
+
if (field.max !== undefined && num > field.max) {
|
|
445
|
+
return { invalid: { code: 'max', text: t('max', { max: field.max }) } };
|
|
446
|
+
}
|
|
397
447
|
return { value: num, dropped: [] };
|
|
398
448
|
}
|
|
399
449
|
case 'boolean':
|
|
400
|
-
return typeof value === 'boolean'
|
|
401
|
-
? { value, dropped: [] }
|
|
402
|
-
: { invalid: `期望 boolean,得到 ${describeType(value)}` };
|
|
450
|
+
return typeof value === 'boolean' ? { value, dropped: [] } : { invalid: mismatch('expectedBoolean', value) };
|
|
403
451
|
case 'select': {
|
|
404
452
|
const text = asText(value);
|
|
405
453
|
if (text === undefined)
|
|
406
|
-
return { invalid:
|
|
454
|
+
return { invalid: mismatch('expectedChoice', value) };
|
|
407
455
|
const options = field.allowCustom === true ? undefined : choiceOptions(field);
|
|
408
456
|
if (!options)
|
|
409
457
|
return { value: text, dropped: [] };
|
|
@@ -412,23 +460,27 @@ function judgeField(field, value) {
|
|
|
412
460
|
}
|
|
413
461
|
case 'multiselect': {
|
|
414
462
|
if (!Array.isArray(value))
|
|
415
|
-
return { invalid:
|
|
463
|
+
return { invalid: mismatch('expectedArray', value) };
|
|
416
464
|
const options = field.allowCustom === true ? undefined : choiceOptions(field);
|
|
417
465
|
const kept = [];
|
|
418
466
|
const dropped = [];
|
|
467
|
+
const keep = (choice) => {
|
|
468
|
+
if (field.ordered !== true || !kept.includes(choice))
|
|
469
|
+
kept.push(choice);
|
|
470
|
+
};
|
|
419
471
|
for (let i = 0; i < value.length; i++) {
|
|
420
472
|
const item = value[i];
|
|
421
473
|
const text = asText(item);
|
|
422
474
|
if (text === undefined) {
|
|
423
|
-
dropped.push({ at: `[${i}]`, reason:
|
|
475
|
+
dropped.push({ at: `[${i}]`, reason: mismatch('expectedChoiceItem', item) });
|
|
424
476
|
}
|
|
425
477
|
else if (!options) {
|
|
426
|
-
|
|
478
|
+
keep(text);
|
|
427
479
|
}
|
|
428
480
|
else {
|
|
429
481
|
const hit = options.find(o => String(o.value) === text);
|
|
430
482
|
if (hit)
|
|
431
|
-
|
|
483
|
+
keep(hit.value);
|
|
432
484
|
else
|
|
433
485
|
dropped.push({ at: `[${i}]`, reason: notAnOption(options) });
|
|
434
486
|
}
|
|
@@ -437,14 +489,14 @@ function judgeField(field, value) {
|
|
|
437
489
|
}
|
|
438
490
|
case 'list': {
|
|
439
491
|
if (!Array.isArray(value))
|
|
440
|
-
return { invalid:
|
|
492
|
+
return { invalid: mismatch('expectedArray', value) };
|
|
441
493
|
const kept = [];
|
|
442
494
|
const dropped = [];
|
|
443
495
|
for (let i = 0; i < value.length; i++) {
|
|
444
496
|
const item = value[i];
|
|
445
497
|
const text = asText(item);
|
|
446
498
|
if (text === undefined)
|
|
447
|
-
dropped.push({ at: `[${i}]`, reason:
|
|
499
|
+
dropped.push({ at: `[${i}]`, reason: mismatch('expectedStringItem', item) });
|
|
448
500
|
else
|
|
449
501
|
kept.push(text);
|
|
450
502
|
}
|
|
@@ -452,16 +504,16 @@ function judgeField(field, value) {
|
|
|
452
504
|
}
|
|
453
505
|
case 'map': {
|
|
454
506
|
if (!isRecord(value))
|
|
455
|
-
return { invalid:
|
|
507
|
+
return { invalid: mismatch('expectedMap', value) };
|
|
456
508
|
const kept = {};
|
|
457
509
|
const dropped = [];
|
|
458
510
|
for (const [k, v] of Object.entries(value)) {
|
|
459
511
|
const text = asText(v);
|
|
460
512
|
if (text === undefined)
|
|
461
|
-
dropped.push({ at: `.${k}`, reason:
|
|
513
|
+
dropped.push({ at: `.${k}`, reason: mismatch('expectedStringValue', v) });
|
|
462
514
|
// 危险键不进结果对象:写 `__proto__` 会改掉结果的原型
|
|
463
515
|
else if (isUnsafeConfigKey(k))
|
|
464
|
-
dropped.push({ at: `.${k}`, reason: '
|
|
516
|
+
dropped.push({ at: `.${k}`, reason: { code: 'reserved-key', text: t('reservedKey') } });
|
|
465
517
|
else
|
|
466
518
|
kept[k] = text;
|
|
467
519
|
}
|
|
@@ -471,6 +523,29 @@ function judgeField(field, value) {
|
|
|
471
523
|
return { value, dropped: [] };
|
|
472
524
|
}
|
|
473
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* 拿换算后的取值调字段自己的 validate:合法给 undefined。validate 抛错、或返回的不是非空字符串与文字对象时,原因写
|
|
528
|
+
* 「校验时出错」(抛出的错里可能带着取值,不转述)
|
|
529
|
+
*/
|
|
530
|
+
function runValidate(field, value) {
|
|
531
|
+
if (field.validate === undefined)
|
|
532
|
+
return undefined;
|
|
533
|
+
let reason;
|
|
534
|
+
try {
|
|
535
|
+
reason = field.validate(value);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return { code: 'validate', text: t('validateError') };
|
|
539
|
+
}
|
|
540
|
+
if (reason === undefined)
|
|
541
|
+
return undefined;
|
|
542
|
+
if (isTextRef(reason))
|
|
543
|
+
return { code: 'validate', text: reason };
|
|
544
|
+
return {
|
|
545
|
+
code: 'validate',
|
|
546
|
+
text: typeof reason === 'string' && reason !== '' ? t('validateFailed', { reason }) : t('validateError'),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
474
549
|
/**
|
|
475
550
|
* 填的是空串(WebUI 与脚手架用 `''` 表示未填)。声明了 format 的 string / textarea 交给插件的是去掉首尾空白的那份
|
|
476
551
|
* (见 judgeField),只有空白同样算空串
|
|
@@ -481,6 +556,12 @@ function isBlank(field, value) {
|
|
|
481
556
|
const trims = field.format !== undefined && (field.type === 'string' || field.type === 'textarea');
|
|
482
557
|
return (trims ? value.trim() : value) === '';
|
|
483
558
|
}
|
|
559
|
+
/** 没配置:undefined 与 null(YAML 裸键);required 字段另把空串算进来(见 isBlank),外来类型的 `''` 一律算 */
|
|
560
|
+
function isUnset(field, value) {
|
|
561
|
+
if (value === undefined || value === null)
|
|
562
|
+
return true;
|
|
563
|
+
return field.required ? isBlank(field, value) : value === '' && !NEUTRAL_FIELD_TYPES.has(field.type);
|
|
564
|
+
}
|
|
484
565
|
/** 期望字符串的位置:字符串原样,有限数字转成字符串(YAML 里不加引号的 QQ 号、端口照常可用) */
|
|
485
566
|
function asText(value) {
|
|
486
567
|
if (typeof value === 'string')
|
|
@@ -506,7 +587,7 @@ function choiceOptions(field) {
|
|
|
506
587
|
return options.length > 0 ? options : undefined;
|
|
507
588
|
}
|
|
508
589
|
function notAnOption(options) {
|
|
509
|
-
return
|
|
590
|
+
return { code: 'not-option', text: t('notOption', { options: options.map(o => JSON.stringify(o.value)) }) };
|
|
510
591
|
}
|
|
511
592
|
function matchesPattern(pattern, text) {
|
|
512
593
|
// 模式本身非法(作者的 schema bug)时跳过该检查——判定不得因 schema
|
|
@@ -550,19 +631,22 @@ class UnrecoverableField extends Error {
|
|
|
550
631
|
missing;
|
|
551
632
|
fatal;
|
|
552
633
|
constructor(path, reason, missing, fatal = false) {
|
|
553
|
-
super(`${path} ${reason}`);
|
|
634
|
+
super(`${path} ${reason.source}`);
|
|
554
635
|
this.path = path;
|
|
555
636
|
this.reason = reason;
|
|
556
637
|
this.missing = missing;
|
|
557
638
|
this.fatal = fatal;
|
|
558
639
|
}
|
|
559
640
|
}
|
|
560
|
-
/**
|
|
561
|
-
|
|
641
|
+
/**
|
|
642
|
+
* 只在祖先值显式无效时检查;缺省的容器仍按字段原有的缺省规则解析。`top` 表示查的是整份配置:顶层带 session 的字段
|
|
643
|
+
* parseConfig 不解析,不算
|
|
644
|
+
*/
|
|
645
|
+
function hasStrictField(schema, top = false) {
|
|
562
646
|
if (!isRecord(schema))
|
|
563
647
|
return false;
|
|
564
648
|
return Object.values(schema).some(entry => {
|
|
565
|
-
if (!isRecord(entry))
|
|
649
|
+
if (!isRecord(entry) || (top && isSessionField(entry)))
|
|
566
650
|
return false;
|
|
567
651
|
if (entry.onInvalid === 'error')
|
|
568
652
|
return true;
|
|
@@ -580,6 +664,9 @@ function parseEntries(schema, source, prefix, warn) {
|
|
|
580
664
|
for (const [key, entry] of Object.entries(entries)) {
|
|
581
665
|
if (!isRecord(entry) || isUnsafeConfigKey(key))
|
|
582
666
|
continue;
|
|
667
|
+
// 顶层(prefix 为空)带 session 的字段不交出:插件经会话设置的读取器读它。键仍在 schema 里,配置里有它也不告警
|
|
668
|
+
if (prefix === '' && isSessionField(entry))
|
|
669
|
+
continue;
|
|
583
670
|
const path = prefix + key;
|
|
584
671
|
// 只认自有属性:`toString` 这类键不能从原型上读到值
|
|
585
672
|
const value = Object.hasOwn(source, key) ? source[key] : undefined;
|
|
@@ -605,25 +692,22 @@ function parseGroup(fields, value, path, warn) {
|
|
|
605
692
|
source = value;
|
|
606
693
|
else if (value !== undefined && value !== null) {
|
|
607
694
|
if (hasStrictField(fields))
|
|
608
|
-
throw new UnrecoverableField(path, '
|
|
695
|
+
throw new UnrecoverableField(path, t('notGroup'), false);
|
|
609
696
|
warn(`配置项 ${path} 期望对象(分组),得到 ${describeType(value)},已忽略`);
|
|
610
697
|
}
|
|
611
698
|
return parseEntries(fields, source, `${path}.`, warn);
|
|
612
699
|
}
|
|
613
700
|
function parseField(field, value, path, warn) {
|
|
614
|
-
|
|
615
|
-
if (value === undefined || value === null || (field.required ? isBlank(field, value) : value === '' && !neutral)) {
|
|
701
|
+
if (isUnset(field, value)) {
|
|
616
702
|
if (field.default !== undefined)
|
|
617
703
|
return cloneConfigValue(field.default);
|
|
618
704
|
if (field.required)
|
|
619
|
-
throw new UnrecoverableField(path, '
|
|
705
|
+
throw new UnrecoverableField(path, t('missing'), true);
|
|
620
706
|
return undefined;
|
|
621
707
|
}
|
|
622
708
|
if (field.secret === true && isSecretRef(value)) {
|
|
623
|
-
throw new UnrecoverableField(path,
|
|
709
|
+
throw new UnrecoverableField(path, t('secretUnresolved', { key: value.$secret }), false, true);
|
|
624
710
|
}
|
|
625
|
-
if (!neutral)
|
|
626
|
-
return cloneConfigValue(value);
|
|
627
711
|
const verdict = judgeField(field, value);
|
|
628
712
|
if ('invalid' in verdict)
|
|
629
713
|
return fallBack(field, path, verdict.invalid, warn);
|
|
@@ -634,8 +718,11 @@ function parseField(field, value, path, warn) {
|
|
|
634
718
|
throw new UnrecoverableField(memberPath, safeInvalidReason(first.reason), false);
|
|
635
719
|
}
|
|
636
720
|
for (const d of verdict.dropped)
|
|
637
|
-
warn(`配置项 ${path}${d.at} ${d.reason},已忽略`);
|
|
638
|
-
|
|
721
|
+
warn(`配置项 ${path}${d.at} ${d.reason.text.source},已忽略`);
|
|
722
|
+
const invalid = runValidate(field, verdict.value);
|
|
723
|
+
if (invalid)
|
|
724
|
+
return fallBack(field, path, invalid, warn);
|
|
725
|
+
return cloneConfigValue(verdict.value);
|
|
639
726
|
}
|
|
640
727
|
function parseArray(entry, value, path, warn) {
|
|
641
728
|
let list = value;
|
|
@@ -644,8 +731,8 @@ function parseArray(entry, value, path, warn) {
|
|
|
644
731
|
}
|
|
645
732
|
else if (!Array.isArray(value)) {
|
|
646
733
|
if (hasStrictField(entry.items))
|
|
647
|
-
throw new UnrecoverableField(path, '
|
|
648
|
-
list = fallBack(entry, path,
|
|
734
|
+
throw new UnrecoverableField(path, t('notArray'), false);
|
|
735
|
+
list = fallBack(entry, path, mismatch('expectedArray', value), warn);
|
|
649
736
|
}
|
|
650
737
|
// 默认值同样逐元素解析:元素字段的默认值要补齐;default 本身不是数组(畸形 schema)按未配置处理
|
|
651
738
|
if (!Array.isArray(list))
|
|
@@ -656,7 +743,7 @@ function parseArray(entry, value, path, warn) {
|
|
|
656
743
|
const at = `${path}[${i}]`;
|
|
657
744
|
if (!isRecord(item)) {
|
|
658
745
|
if (entry.onInvalid === 'error')
|
|
659
|
-
throw new UnrecoverableField(at, '
|
|
746
|
+
throw new UnrecoverableField(at, t('notItem'), false);
|
|
660
747
|
warn(`配置项 ${at} 已忽略:期望对象元素,得到 ${describeType(item)}`);
|
|
661
748
|
continue;
|
|
662
749
|
}
|
|
@@ -670,7 +757,7 @@ function parseArray(entry, value, path, warn) {
|
|
|
670
757
|
throw err;
|
|
671
758
|
if (entry.onInvalid === 'error' || err.fatal)
|
|
672
759
|
throw err;
|
|
673
|
-
warn(`配置项 ${at} 已忽略:${err.path.slice(at.length + 1)} ${err.reason}`);
|
|
760
|
+
warn(`配置项 ${at} 已忽略:${err.path.slice(at.length + 1)} ${err.reason.source}`);
|
|
674
761
|
continue;
|
|
675
762
|
}
|
|
676
763
|
for (const message of pending)
|
|
@@ -683,21 +770,24 @@ function fallBack(entry, path, reason, warn) {
|
|
|
683
770
|
if (entry.onInvalid === 'error')
|
|
684
771
|
throw new UnrecoverableField(path, safeInvalidReason(reason), false);
|
|
685
772
|
if (entry.default !== undefined) {
|
|
686
|
-
warn(`配置项 ${path} ${reason},改用默认值 ${describeDefault(entry.default)}`);
|
|
773
|
+
warn(`配置项 ${path} ${reason.text.source},改用默认值 ${describeDefault(entry.default)}`);
|
|
687
774
|
return cloneConfigValue(entry.default);
|
|
688
775
|
}
|
|
689
776
|
if (entry.required)
|
|
690
|
-
throw new UnrecoverableField(path, reason, false);
|
|
691
|
-
warn(`配置项 ${path} ${reason},已忽略`);
|
|
777
|
+
throw new UnrecoverableField(path, reason.text, false);
|
|
778
|
+
warn(`配置项 ${path} ${reason.text.source},已忽略`);
|
|
692
779
|
return undefined;
|
|
693
780
|
}
|
|
694
|
-
/**
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
781
|
+
/**
|
|
782
|
+
* 严格错误的原因:可说明类型与约束,但不列出静态选项值与正则文本,按 code 换成不带它们的那句。类型不符只写类型名
|
|
783
|
+
* (非有限数写成 NaN、Infinity,它们不会是密钥),本来就不带用户值
|
|
784
|
+
*/
|
|
785
|
+
function safeInvalidReason({ code, text }) {
|
|
786
|
+
if (code === 'not-option')
|
|
787
|
+
return t('notOptionHidden');
|
|
788
|
+
if (code === 'pattern')
|
|
789
|
+
return t('patternHidden');
|
|
790
|
+
return text;
|
|
701
791
|
}
|
|
702
792
|
function describeDefault(value) {
|
|
703
793
|
try {
|
|
@@ -707,7 +797,7 @@ function describeDefault(value) {
|
|
|
707
797
|
return String(value);
|
|
708
798
|
}
|
|
709
799
|
}
|
|
710
|
-
// 与 packages/core/src/infrastructure/config-values.ts
|
|
800
|
+
// 与 packages/core/src/infrastructure/config-values.ts 同一规则(本包不能 import core 的运行时)。
|
|
711
801
|
// 防漂移:test/architecture/config-copy-parity.test.ts
|
|
712
802
|
const UNSAFE_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
713
803
|
/** 配置层无合法含义、落到原型链的键(`__proto__` / `constructor` / `prototype`);宿主的配置文档按它拦 */
|