@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/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
- // 本包零运行时依赖:对 @aalis/core 仅有 type-only 锚点 import(编译后擦除)。
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: '插件激活超过它仍未完成时告警并转入后台继续(它提供的服务在激活完成前不对依赖方开放),app 启动与停机阶段的单个事件监听器超过它时告警并不再等待;0 表示不设限;重启生效',
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 一律跳过放行、parseConfig 原样透传——开放词汇表要求两者同样开放。
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
- /** 字段类型是不是本包内置的:内置类型的取值经 validateConfig 与 parseConfig 校验,外来类型(如 'llm-ref')的不经 */
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 与 secret;
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('配置整体期望对象,在 WebUI 或配置文件中改正后生效');
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(`配置项 ${err.path} ${err.reason},在 WebUI 或配置文件中改正后生效`);
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(`缺少配置项 ${field}${note ? `(${note})` : ''},在 WebUI 或配置文件中填入后生效`);
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
- issues.push({ path, message: `期望对象(分组),得到 ${describeType(value)}`, kind: 'invalid' });
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
- issues.push({ path, message: `期望数组,得到 ${describeType(value)}`, kind: 'invalid' });
377
+ push(path, 'invalid', mismatch('expectedArray', value));
335
378
  continue;
336
379
  }
337
380
  value.forEach((item, i) => {
338
381
  if (!isRecord(item)) {
339
- issues.push({ path: `${path}[${i}]`, message: `期望对象元素,得到 ${describeType(item)}`, kind: 'invalid' });
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 (value === undefined || value === null || (entry.required && isBlank(entry, value))) {
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
- issues.push({ path, message: '必填字段缺失', kind: 'missing' });
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
- issues.push({ path, message: verdict.invalid, kind: 'invalid' });
402
+ push(path, 'invalid', verdict.invalid);
362
403
  continue;
363
404
  }
364
405
  for (const d of verdict.dropped)
365
- issues.push({ path: path + d.at, message: d.reason, kind: 'invalid' });
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: `期望 string,得到 ${describeType(value)}` };
422
+ return { invalid: mismatch('expectedString', value) };
375
423
  if (field.pattern !== undefined && !matchesPattern(field.pattern, text)) {
376
- return { invalid: `不匹配模式 ${field.pattern}` };
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: `期望有限数值,得到 ${describeType(value)}` };
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: `小于下限 ${field.min}` };
395
- if (field.max !== undefined && num > field.max)
396
- return { invalid: `大于上限 ${field.max}` };
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: `期望 string 或 number,得到 ${describeType(value)}` };
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: `期望数组,得到 ${describeType(value)}` };
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: `期望 string 或 number 元素,得到 ${describeType(item)}` });
475
+ dropped.push({ at: `[${i}]`, reason: mismatch('expectedChoiceItem', item) });
424
476
  }
425
477
  else if (!options) {
426
- kept.push(text);
478
+ keep(text);
427
479
  }
428
480
  else {
429
481
  const hit = options.find(o => String(o.value) === text);
430
482
  if (hit)
431
- kept.push(hit.value);
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: `期望数组,得到 ${describeType(value)}` };
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: `期望 string 元素,得到 ${describeType(item)}` });
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: `期望对象(映射),得到 ${describeType(value)}` };
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: `期望 string 值,得到 ${describeType(v)}` });
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 `不是可选值(${options.map(o => JSON.stringify(o.value)).join('、')})之一`;
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
- function hasStrictField(schema) {
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, '期望对象(分组)', false);
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
- const neutral = NEUTRAL_FIELD_TYPES.has(field.type);
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, '必填字段缺失', true);
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, `引用的密钥 ${value.$secret} 不在宿主的密钥存储里`, false, true);
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
- return verdict.value;
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, '期望数组', false);
648
- list = fallBack(entry, path, `期望数组,得到 ${describeType(value)}`, warn);
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, '期望对象元素', false);
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
- function safeInvalidReason(reason) {
696
- if (reason.startsWith('不是可选值'))
697
- return '不是可选值';
698
- if (reason.startsWith('不匹配模式'))
699
- return '不匹配模式';
700
- return reason.replace(/得到 (?:NaN|-?Infinity)/g, '得到 number');
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 同一规则(本包零运行时依赖,不能 import core)。
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`);宿主的配置文档按它拦 */