@aalis/schema-config 0.14.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,70 @@
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';
22
+ /** 是不是密钥引用:只有 `$secret` 一个自有键、值为非空字符串的纯对象 */
23
+ export function isSecretRef(value) {
24
+ if (!isRecord(value))
25
+ return false;
26
+ const keys = Object.keys(value);
27
+ return keys.length === 1 && keys[0] === '$secret' && typeof value.$secret === 'string' && value.$secret !== '';
28
+ }
29
+ /**
30
+ * 逐个处理配置里密钥字段上的值(顶层、分组内、数组元素内;值为 undefined 的不处理),不改入参:有改动时返回浅拷贝
31
+ * (没动到的部分与入参共享),没有改动时返回入参本身,调用方要改写结果请先复制。回调返回 undefined 时删掉这个键。
32
+ * 宿主拆出、解析密钥与给呈现方去掉密钥都用它,密钥字段的位置只在这里认。
33
+ */
34
+ export function mapSecretFields(schema, config, fn) {
35
+ const visit = (fields, source, prefix, inArray) => {
36
+ let out = source;
37
+ const put = (key, value) => {
38
+ if (out === source)
39
+ out = { ...source };
40
+ if (value === undefined)
41
+ delete out[key];
42
+ else
43
+ out[key] = value;
44
+ };
45
+ for (const [key, entry] of Object.entries(fields)) {
46
+ if (!isRecord(entry) || isUnsafeConfigKey(key) || !Object.hasOwn(source, key))
47
+ continue;
48
+ const value = source[key];
49
+ if ('fields' in entry) {
50
+ if (isRecord(value) && isRecord(entry.fields)) {
51
+ const next = visit(entry.fields, value, `${prefix}${key}.`, inArray);
52
+ if (next !== value)
53
+ put(key, next);
54
+ }
55
+ }
56
+ else if (entry.type === 'array') {
57
+ if (Array.isArray(value) && isRecord(entry.items)) {
58
+ const items = entry.items;
59
+ const next = value.map(item => (isRecord(item) ? visit(items, item, `${prefix}${key}.`, true) : item));
60
+ if (next.some((item, i) => item !== value[i]))
61
+ put(key, next);
62
+ }
63
+ }
64
+ else if (entry.secret === true && value !== undefined) {
65
+ const next = fn(value, { path: prefix + key, inArray });
66
+ if (next !== value)
67
+ put(key, next);
68
+ }
69
+ }
70
+ return out;
71
+ };
72
+ return visit(isRecord(schema) ? schema : {}, config, '', false);
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
+ }
19
81
  /**
20
82
  * 声明插件的配置 schema:运行时原样返回传入的对象(不冻结、不加任何属性),类型层保留字面量,
21
83
  * 供 {@link ConfigOf} 推出配置值类型。返回值可直接放进 `definePlugin({ configSchema })`。
@@ -36,14 +98,14 @@ export function defineConfig(schema) {
36
98
  export const CORE_CONFIG_SCHEMA = {
37
99
  name: {
38
100
  type: 'string',
39
- label: '应用名称',
40
- description: '应用显示名称,用于启动日志和界面展示;装有人设时仪表盘仍显示它,聊天显示人设名',
101
+ label: t('nameLabel'),
102
+ description: t('nameDescription'),
41
103
  default: 'Aalis',
42
104
  },
43
105
  logLevel: {
44
106
  type: 'select',
45
- label: '日志等级',
46
- description: '日志输出等级',
107
+ label: t('logLevelLabel'),
108
+ description: t('logLevelDescription'),
47
109
  default: 'info',
48
110
  options: [
49
111
  { label: 'debug', value: 'debug' },
@@ -54,14 +116,19 @@ export const CORE_CONFIG_SCHEMA = {
54
116
  },
55
117
  slowThresholdMs: {
56
118
  type: 'number',
57
- label: '慢操作阈值(毫秒)',
58
- description: '插件激活超过它仍未完成时告警并转入后台继续(它提供的服务在激活完成前不对依赖方开放),app 启动与停机阶段的单个事件监听器超过它时告警并不再等待;0 表示不设限;重启生效',
119
+ label: t('slowThresholdLabel'),
120
+ description: t('slowThresholdDescription'),
59
121
  default: 60000,
60
122
  min: 0,
61
123
  },
62
124
  };
63
125
  /**
64
- * 从 ConfigSchema 派生默认配置。
126
+ * 宿主配置里只在启动时读、改了要重启才生效的核心键:runtime 启动时读它们交给 core,WebUI 设置页据此标「重启后生效」。
127
+ * runtime 改了哪些键只在启动时读,同步改这一份。name 只是显示用的应用名称,保存即生效
128
+ */
129
+ export const CORE_RESTART_KEYS = ['logLevel', 'slowThresholdMs'];
130
+ /**
131
+ * 从 ConfigSchema 派生默认配置(密钥字段除外)。
65
132
  *
66
133
  * ConfigSchema 是插件配置的**唯一声明来源**:每个字段的 `default` 就是运行时默认值,
67
134
  * 不存在第二份手抄的默认值对象。宿主在注册插件前用本函数派生出默认配置并深合并进配置文档,
@@ -71,6 +138,8 @@ export const CORE_CONFIG_SCHEMA = {
71
138
  * - SchemaField / SchemaArray:取 `default`(没写 default 的字段不产出键,
72
139
  * 等同于「该字段无默认值」——读取方自行处理 undefined)
73
140
  * - SchemaGroup:递归 `fields`,总是产出嵌套对象(即使子字段全无默认值)
141
+ * - `secret: true` 的字段不产出:派生结果会被写进配置文档,密钥的默认值不进文档(map 的默认值还会被深合并进
142
+ * 引用对象),插件经 parseConfig 照常补上
74
143
  */
75
144
  export function defaultsFrom(schema) {
76
145
  const out = {};
@@ -83,6 +152,8 @@ export function defaultsFrom(schema) {
83
152
  out[key] = defaultsFrom(entry.fields);
84
153
  continue;
85
154
  }
155
+ if (entry.secret === true)
156
+ continue;
86
157
  if ('default' in entry)
87
158
  out[key] = cloneConfigValue(entry.default);
88
159
  }
@@ -111,8 +182,8 @@ export function deepMergeDefaults(defaults, current) {
111
182
  return result;
112
183
  }
113
184
  // 校验与解析在运行时需要一份"本包内置类型"名单:SchemaFieldTypes 是纯类型层的
114
- // merging 注册表,运行时不存在。外来类型(如 'llm-ref')落在名单之外,
115
- // validateConfig 一律跳过放行、parseConfig 原样透传——开放词汇表要求两者同样开放。
185
+ // merging 注册表,运行时不存在。外来类型(如 'llm-ref')落在名单之外,不经类型判定、只过字段自己的 validate:
186
+ // validateConfig 放行、parseConfig 原样透传——开放词汇表要求两者同样开放。
116
187
  const NEUTRAL_FIELD_TYPES = new Set([
117
188
  'string',
118
189
  'number',
@@ -123,25 +194,36 @@ const NEUTRAL_FIELD_TYPES = new Set([
123
194
  'list',
124
195
  'map',
125
196
  ]);
197
+ /**
198
+ * 字段类型是不是本包内置的:内置类型的取值经 validateConfig 与 parseConfig 按类型校验,外来类型(如 'llm-ref')的
199
+ * 只过字段的 validate
200
+ */
201
+ export function isBuiltinFieldType(type) {
202
+ return NEUTRAL_FIELD_TYPES.has(type);
203
+ }
126
204
  /**
127
205
  * 按 ConfigSchema 对配置做只读结构校验,返回问题清单(空数组 = 无问题)。
128
206
  *
129
207
  * 定位与边界:
130
208
  * - **只读**:不转换、不回填、不裁剪——回填是 defaultsFrom,裁剪是 removeExtraFields,取值是 parseConfig。
131
- * 逐字段判定与 parseConfig 共用同一份:这里报 invalid 的值,parseConfig 会回落、忽略或拒绝。
209
+ * 逐字段判定与 parseConfig 共用同一份:这里报 invalid 的值,parseConfig 会回落、忽略或拒绝(带 session 的顶层字段除外,
210
+ * parseConfig 不交出它)。
132
211
  * 校验器出错的最大代价必须始终是"少一条警告",因此它对畸形 schema 不抛错。
133
- * - **只解释本包的词汇**:type/required、约束键与影响取值的 dynamicOptions/allowCustom;
134
- * 宿主经 declaration merging 注入的呈现属性(如 secret)一概不解读。
212
+ * - **只解释本包的词汇**:type/required、约束键、影响取值的 dynamicOptions/allowCustom、secret 与字段自己的 validate;
213
+ * 宿主经 declaration merging 注入的呈现属性一概不解读。带 session 的字段照常校验(它的全局层就在配置文档里)。
214
+ * - **校验的是文档**:`secret` 字段上的 {@link SecretRef} 算合法(值在宿主的密钥存储里),不查引用的键在不在。
135
215
  * - **标量宽容**:期望字符串的位置(string/textarea、list 元素、map 值、选项比较)接受有限数字;
136
216
  * 期望数字的位置接受去掉首尾空白后能完整解析为有限数的字符串。boolean 不做转换。
137
217
  * - **options 即取值范围**:select 有静态 options、没有 dynamicOptions 且 allowCustom 不为 true 时,值须是
138
218
  * 某个选项值(按字符串比较);multiselect 同样逐元素判定。
139
219
  * - **约束键**:number 强制 min/max(含边界)与 integer,string/textarea 强制
140
- * pattern(模式非法则跳过该检查);step 是纯 UI 提示不校验。约束只在类型
220
+ * pattern(模式非法则跳过该检查)与 format(空串与只有空白不查);step 是纯 UI 提示不校验。约束只在类型
141
221
  * 检查通过后评估——类型都不对时报类型错,不叠报约束错。
222
+ * - **validate**:类型与约束键都过了才调,拿换算后的取值;返回原因报 invalid(code 为 `validate`),抛错同样报,不外传。
223
+ * list、map、multiselect 有坏元素时只报元素、不调它:它拿到的会是剔掉坏元素的值,报的原因对不上文档里写的。
142
224
  * - **list / map**:list 须为数组、map 须为对象,逐元素 / 逐值报错,路径形如 `args[1]`、`env.KEY`。
143
225
  * - **宽容缺失**:undefined 与 null(YAML 裸键)视为"未配置",required 字段的 `''` 也算未配置(WebUI 与脚手架
144
- * 用 `''` 表示未填,与 parseConfig 一致;空数组仍算已配置),仅在 required 且未声明 default 时报缺——顶层/分组的默认值在调用点已合并,数组元素的默认值
226
+ * 用 `''` 表示未填,与 parseConfig 一致;声明了 format 的只有空白也算;空数组仍算已配置),仅在 required 且未声明 default 时报缺——顶层/分组的默认值在调用点已合并,数组元素的默认值
145
227
  * 不参与合并(defaultsFrom 把 SchemaArray 当叶子),靠 default 声明本身放行。
146
228
  *
147
229
  * 政策留给调用方:config-sync 对启用插件打 warn(禁用插件的配置是休眠数据,不告警),
@@ -156,12 +238,17 @@ export function validateConfig(schema, config) {
156
238
  * 按 schema 解析插件拿到的配置,返回 {@link ConfigOf} 类型的新对象(不改入参)。插件在 apply 里用它读配置:
157
239
  * `const cfg = parseConfig(configSchema, caps.config, caps.logger)`,之后的夹紧、跨字段约束等核对作用于 cfg。
158
240
  *
159
- * - **缺失**:undefined 与 null;required 字段另把 `''` 当作缺失(WebUI 与脚手架用 `''` 表示未填),
160
- * 外来类型的 `''` 一律当作缺失。有 default 用 default(拷贝),无 default 且 required 不可恢复,否则省略该键。
161
- * - **无效**:逐字段判定与 validateConfig 共用(标量宽容、选项即取值范围、约束键)。
241
+ * - **缺失**:undefined 与 null;required 字段另把 `''` 当作缺失(WebUI 与脚手架用 `''` 表示未填;声明了 format 的
242
+ * 只有空白也算),外来类型的 `''` 一律当作缺失。有 default 用 default(拷贝),无 default 且 required 不可恢复,否则省略该键。
243
+ * - **无效**:逐字段判定与 validateConfig 共用(标量宽容、选项即取值范围、约束键、字段的 validate)。
244
+ * list、map、multiselect 先丢坏元素,validate 判的是剩下的值。
162
245
  * 默认有 default 回落并告警;无 default 且 required 不可恢复;否则省略并告警。
163
246
  * `onInvalid: 'error'` 的字段遇到显式无效值或坏集合成员时,即使有 default 也不可恢复。
164
- * - **外来类型**:除 `''` 外原样透传,不校验。
247
+ * - **外来类型**:除 `''` 外原样透传,只过字段的 validate。
248
+ * - **带 session 的顶层字段**:不交出、不补 default,配置里有这个键也不告警(插件经会话设置的读取器读)。
249
+ * - **密钥字段**:宿主交给插件之前已把引用解析成原值;`secret` 字段上仍是 {@link SecretRef},说明引用的键不在
250
+ * 宿主的密钥存储里,一律不可恢复(不回落 default,数组元素里的也不只丢这一条),消息点名键名。owner 写了引用,
251
+ * 引用落空就是配置错误,回落默认值会悄悄连上别的东西(例如默认的本机数据库)。
165
252
  * - **list / map / multiselect**:默认坏元素、坏值逐个丢弃并告警;严格字段拒绝整个值。
166
253
  * - **分组**:值不是对象时默认告警并按空对象解析;含严格字段时拒绝显式无效的分组值。
167
254
  * - **数组**:值不是数组按无效处理,含严格元素字段时拒绝显式无效的数组值;元素不是对象、或元素内有不可恢复的字段时只丢这一条元素并告警;
@@ -169,7 +256,7 @@ export function validateConfig(schema, config) {
169
256
  * - **schema 外的键**:丢弃并告警。
170
257
  * - **不可恢复**(顶层或分组内):缺失抛 {@link missingConfigError},无效抛 {@link configError},插件随之进 error 态。
171
258
  *
172
- * 告警只写路径、类型名与约束,不写原值(可能是密钥);默认值可以写。
259
+ * 告警只写路径、类型名与约束,不写原值(可能是密钥);默认值可以写。字段 validate 返回的原因原样写进去。
173
260
  */
174
261
  export function parseConfig(schema, raw, logger) {
175
262
  const warn = (message) => logger?.warn(message);
@@ -177,8 +264,8 @@ export function parseConfig(schema, raw, logger) {
177
264
  if (isRecord(raw))
178
265
  source = raw;
179
266
  else if (raw !== undefined && raw !== null) {
180
- if (hasStrictField(schema))
181
- throw configError('配置整体期望对象,在 WebUI 或配置文件中改正后生效');
267
+ if (hasStrictField(schema, true))
268
+ throw configError(t('notObject'));
182
269
  warn(`配置整体期望对象,得到 ${describeType(raw)},已忽略`);
183
270
  }
184
271
  try {
@@ -189,9 +276,27 @@ export function parseConfig(schema, raw, logger) {
189
276
  throw err;
190
277
  throw err.missing
191
278
  ? missingConfigError(err.path)
192
- : configError(`配置项 ${err.path} ${err.reason},在 WebUI 或配置文件中改正后生效`);
279
+ : configError(t('invalid', { path: err.path, reason: err.reason }));
193
280
  }
194
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
+ }
195
300
  /**
196
301
  * 按 schema 键集裁掉未知字段。configSchema 是插件配置的**唯一声明来源**(默认值也从它派生),
197
302
  * 所以它的键集就是完整的白名单:不在 schema 里的字段,要么是用户手写的错别字,
@@ -230,22 +335,24 @@ export function removeExtraFields(config, schema, removed, prefix = '') {
230
335
  *
231
336
  * 实例照常转入 error 态,但这不是程序出错:core 的 DefaultLogger 对没有 stack 的错误只写「名称: 消息」,
232
337
  * 激活失败日志因此只有一行,不像程序崩溃。缺必填项用 {@link missingConfigError}。
338
+ * 收文字对象时挂上它(schema-i18n 的 withText,name 不变),出口按读者语言显示,message 是它的原文。
233
339
  */
234
340
  export function configError(message) {
235
- const error = new Error(message);
341
+ const error = new Error(typeof message === 'string' ? message : message.source);
236
342
  error.name = 'ConfigError';
237
343
  // 定义成自有属性而不是 delete:有的引擎把 stack 做成原型上的访问器,删实例属性去不掉
238
344
  Object.defineProperty(error, 'stack', { value: undefined, writable: true, configurable: true });
239
- return error;
345
+ return typeof message === 'string' ? error : withText(error, message);
240
346
  }
241
347
  /**
242
348
  * 缺少必填配置时的 {@link configError}:消息点名缺的字段(`note` 是放在字段名后括号里的补充说明),并说明
243
349
  * 填入后生效——WebUI 保存或配置文件热重载会重建实例、重试激活。
244
350
  */
245
351
  export function missingConfigError(field, note) {
246
- return configError(`缺少配置项 ${field}${note ? `(${note})` : ''},在 WebUI 或配置文件中填入后生效`);
352
+ return configError(note ? t('missingFieldNote', { field, note }) : t('missingField', { field }));
247
353
  }
248
354
  function validateFields(schema, config, prefix, issues) {
355
+ const push = (path, kind, { code, text }) => issues.push({ path, message: text.source, kind, code, text });
249
356
  // `?? {}` 与 defaultsFrom 同款兜底:畸形 schema(array 缺 items / fields 为 null)
250
357
  // 不许把"最多少一条警告"升级成 TypeError——校验器自身永远不能成为故障源。
251
358
  for (const [key, entry] of Object.entries(schema ?? {})) {
@@ -257,7 +364,7 @@ function validateFields(schema, config, prefix, issues) {
257
364
  if (value === undefined || value === null)
258
365
  continue;
259
366
  if (!isRecord(value)) {
260
- issues.push({ path, message: `期望对象(分组),得到 ${describeType(value)}`, kind: 'invalid' });
367
+ push(path, 'invalid', mismatch('expectedGroup', value));
261
368
  continue;
262
369
  }
263
370
  validateFields(entry.fields, value, `${path}.`, issues);
@@ -267,70 +374,84 @@ function validateFields(schema, config, prefix, issues) {
267
374
  if (value === undefined || value === null)
268
375
  continue;
269
376
  if (!Array.isArray(value)) {
270
- issues.push({ path, message: `期望数组,得到 ${describeType(value)}`, kind: 'invalid' });
377
+ push(path, 'invalid', mismatch('expectedArray', value));
271
378
  continue;
272
379
  }
273
380
  value.forEach((item, i) => {
274
381
  if (!isRecord(item)) {
275
- issues.push({ path: `${path}[${i}]`, message: `期望对象元素,得到 ${describeType(item)}`, kind: 'invalid' });
382
+ push(`${path}[${i}]`, 'invalid', mismatch('expectedItem', item));
276
383
  return;
277
384
  }
278
385
  validateFields(entry.items, item, `${path}[${i}].`, issues);
279
386
  });
280
387
  continue;
281
388
  }
282
- if (value === undefined || value === null || (value === '' && entry.required)) {
389
+ if (isUnset(entry, value)) {
283
390
  // 声明了 default 的字段永远不算缺失:顶层/分组的默认值多已在调用点合并,但合并不覆盖
284
391
  // 显式 null(YAML 裸键),这类值照样走到这里,
285
392
  // 数组元素的默认值不参与任何合并(defaultsFrom 把 SchemaArray 当叶子),
286
393
  // 全靠这里放行——否则 required+default 的 item 字段会误报(scheduler jobs[].platform 型)。
287
394
  if (entry.required && !('default' in entry))
288
- issues.push({ path, message: '必填字段缺失', kind: 'missing' });
395
+ push(path, 'missing', { code: 'missing', text: t('missing') });
289
396
  continue;
290
397
  }
291
- if (!NEUTRAL_FIELD_TYPES.has(entry.type))
398
+ if (entry.secret === true && isSecretRef(value))
292
399
  continue;
293
400
  const verdict = judgeField(entry, value);
294
401
  if ('invalid' in verdict) {
295
- issues.push({ path, message: verdict.invalid, kind: 'invalid' });
402
+ push(path, 'invalid', verdict.invalid);
296
403
  continue;
297
404
  }
298
405
  for (const d of verdict.dropped)
299
- 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);
300
410
  }
301
411
  }
412
+ function mismatch(expected, value) {
413
+ return { code: 'type', text: t(expected, { got: describeType(value) }) };
414
+ }
415
+ /** 按类型与约束键判,外来类型不判;字段自己的 validate 由调用方处置完 dropped 之后调(runValidate) */
302
416
  function judgeField(field, value) {
303
417
  switch (field.type) {
304
418
  case 'string':
305
419
  case 'textarea': {
306
420
  const text = asText(value);
307
421
  if (text === undefined)
308
- return { invalid: `期望 string,得到 ${describeType(value)}` };
422
+ return { invalid: mismatch('expectedString', value) };
309
423
  if (field.pattern !== undefined && !matchesPattern(field.pattern, text)) {
310
- return { invalid: `不匹配模式 ${field.pattern}` };
424
+ return { invalid: { code: 'pattern', text: t('pattern', { pattern: field.pattern }) } };
425
+ }
426
+ if (field.format === undefined)
427
+ return { value: text, dropped: [] };
428
+ // 路径按去掉首尾空白后判定,交出去的也是这一份:带空白的原值交给插件会被当成相对路径或找不到
429
+ const path = text.trim();
430
+ if (path !== '' && !matchesFormat(field.format, path)) {
431
+ return { invalid: { code: 'format', text: t(field.format === 'executable' ? 'executable' : 'absolutePath') } };
311
432
  }
312
- return { value: text, dropped: [] };
433
+ return { value: path, dropped: [] };
313
434
  }
314
435
  case 'number': {
315
436
  const num = asNumber(value);
316
437
  if (num === undefined)
317
- return { invalid: `期望有限数值,得到 ${describeType(value)}` };
438
+ return { invalid: mismatch('expectedNumber', value) };
318
439
  if (field.integer && !Number.isInteger(num))
319
- return { invalid: '期望整数' };
320
- if (field.min !== undefined && num < field.min)
321
- return { invalid: `小于下限 ${field.min}` };
322
- if (field.max !== undefined && num > field.max)
323
- 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
+ }
324
447
  return { value: num, dropped: [] };
325
448
  }
326
449
  case 'boolean':
327
- return typeof value === 'boolean'
328
- ? { value, dropped: [] }
329
- : { invalid: `期望 boolean,得到 ${describeType(value)}` };
450
+ return typeof value === 'boolean' ? { value, dropped: [] } : { invalid: mismatch('expectedBoolean', value) };
330
451
  case 'select': {
331
452
  const text = asText(value);
332
453
  if (text === undefined)
333
- return { invalid: `期望 string 或 number,得到 ${describeType(value)}` };
454
+ return { invalid: mismatch('expectedChoice', value) };
334
455
  const options = field.allowCustom === true ? undefined : choiceOptions(field);
335
456
  if (!options)
336
457
  return { value: text, dropped: [] };
@@ -339,23 +460,27 @@ function judgeField(field, value) {
339
460
  }
340
461
  case 'multiselect': {
341
462
  if (!Array.isArray(value))
342
- return { invalid: `期望数组,得到 ${describeType(value)}` };
463
+ return { invalid: mismatch('expectedArray', value) };
343
464
  const options = field.allowCustom === true ? undefined : choiceOptions(field);
344
465
  const kept = [];
345
466
  const dropped = [];
467
+ const keep = (choice) => {
468
+ if (field.ordered !== true || !kept.includes(choice))
469
+ kept.push(choice);
470
+ };
346
471
  for (let i = 0; i < value.length; i++) {
347
472
  const item = value[i];
348
473
  const text = asText(item);
349
474
  if (text === undefined) {
350
- dropped.push({ at: `[${i}]`, reason: `期望 string 或 number 元素,得到 ${describeType(item)}` });
475
+ dropped.push({ at: `[${i}]`, reason: mismatch('expectedChoiceItem', item) });
351
476
  }
352
477
  else if (!options) {
353
- kept.push(text);
478
+ keep(text);
354
479
  }
355
480
  else {
356
481
  const hit = options.find(o => String(o.value) === text);
357
482
  if (hit)
358
- kept.push(hit.value);
483
+ keep(hit.value);
359
484
  else
360
485
  dropped.push({ at: `[${i}]`, reason: notAnOption(options) });
361
486
  }
@@ -364,14 +489,14 @@ function judgeField(field, value) {
364
489
  }
365
490
  case 'list': {
366
491
  if (!Array.isArray(value))
367
- return { invalid: `期望数组,得到 ${describeType(value)}` };
492
+ return { invalid: mismatch('expectedArray', value) };
368
493
  const kept = [];
369
494
  const dropped = [];
370
495
  for (let i = 0; i < value.length; i++) {
371
496
  const item = value[i];
372
497
  const text = asText(item);
373
498
  if (text === undefined)
374
- dropped.push({ at: `[${i}]`, reason: `期望 string 元素,得到 ${describeType(item)}` });
499
+ dropped.push({ at: `[${i}]`, reason: mismatch('expectedStringItem', item) });
375
500
  else
376
501
  kept.push(text);
377
502
  }
@@ -379,16 +504,16 @@ function judgeField(field, value) {
379
504
  }
380
505
  case 'map': {
381
506
  if (!isRecord(value))
382
- return { invalid: `期望对象(映射),得到 ${describeType(value)}` };
507
+ return { invalid: mismatch('expectedMap', value) };
383
508
  const kept = {};
384
509
  const dropped = [];
385
510
  for (const [k, v] of Object.entries(value)) {
386
511
  const text = asText(v);
387
512
  if (text === undefined)
388
- dropped.push({ at: `.${k}`, reason: `期望 string 值,得到 ${describeType(v)}` });
513
+ dropped.push({ at: `.${k}`, reason: mismatch('expectedStringValue', v) });
389
514
  // 危险键不进结果对象:写 `__proto__` 会改掉结果的原型
390
515
  else if (isUnsafeConfigKey(k))
391
- dropped.push({ at: `.${k}`, reason: '保留键不可用' });
516
+ dropped.push({ at: `.${k}`, reason: { code: 'reserved-key', text: t('reservedKey') } });
392
517
  else
393
518
  kept[k] = text;
394
519
  }
@@ -398,6 +523,45 @@ function judgeField(field, value) {
398
523
  return { value, dropped: [] };
399
524
  }
400
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
+ }
549
+ /**
550
+ * 填的是空串(WebUI 与脚手架用 `''` 表示未填)。声明了 format 的 string / textarea 交给插件的是去掉首尾空白的那份
551
+ * (见 judgeField),只有空白同样算空串
552
+ */
553
+ function isBlank(field, value) {
554
+ if (typeof value !== 'string')
555
+ return false;
556
+ const trims = field.format !== undefined && (field.type === 'string' || field.type === 'textarea');
557
+ return (trims ? value.trim() : value) === '';
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
+ }
401
565
  /** 期望字符串的位置:字符串原样,有限数字转成字符串(YAML 里不加引号的 QQ 号、端口照常可用) */
402
566
  function asText(value) {
403
567
  if (typeof value === 'string')
@@ -423,7 +587,7 @@ function choiceOptions(field) {
423
587
  return options.length > 0 ? options : undefined;
424
588
  }
425
589
  function notAnOption(options) {
426
- 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)) }) };
427
591
  }
428
592
  function matchesPattern(pattern, text) {
429
593
  // 模式本身非法(作者的 schema bug)时跳过该检查——判定不得因 schema
@@ -435,6 +599,16 @@ function matchesPattern(pattern, text) {
435
599
  return true;
436
600
  }
437
601
  }
602
+ /** POSIX 绝对路径、Windows 盘符路径或 UNC 路径 */
603
+ const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
604
+ function matchesFormat(format, text) {
605
+ if (ABSOLUTE_PATH.test(text))
606
+ return true;
607
+ if (format === 'executable')
608
+ return !/[\\/]/.test(text) && !/^[A-Za-z]:/.test(text);
609
+ // 不认识的写法(作者的 schema bug)同 pattern 一样跳过
610
+ return format !== 'absolute-path';
611
+ }
438
612
  function isRecord(value) {
439
613
  return typeof value === 'object' && value !== null && !Array.isArray(value);
440
614
  }
@@ -447,24 +621,32 @@ function describeType(value) {
447
621
  return String(value);
448
622
  return typeof value;
449
623
  }
450
- /** 顶层或分组内无法恢复的字段:parseConfig 把它转成 missingConfigError / configError 抛出,数组元素内只丢这一条元素 */
624
+ /**
625
+ * 顶层或分组内无法恢复的字段:parseConfig 把它转成 missingConfigError / configError 抛出,数组元素内只丢这一条元素;
626
+ * `fatal` 的(没解析的密钥引用)在数组元素里也让整次解析失败
627
+ */
451
628
  class UnrecoverableField extends Error {
452
629
  path;
453
630
  reason;
454
631
  missing;
455
- constructor(path, reason, missing) {
456
- super(`${path} ${reason}`);
632
+ fatal;
633
+ constructor(path, reason, missing, fatal = false) {
634
+ super(`${path} ${reason.source}`);
457
635
  this.path = path;
458
636
  this.reason = reason;
459
637
  this.missing = missing;
638
+ this.fatal = fatal;
460
639
  }
461
640
  }
462
- /** 只在祖先值显式无效时检查;缺省的容器仍按字段原有的缺省规则解析。 */
463
- function hasStrictField(schema) {
641
+ /**
642
+ * 只在祖先值显式无效时检查;缺省的容器仍按字段原有的缺省规则解析。`top` 表示查的是整份配置:顶层带 session 的字段
643
+ * parseConfig 不解析,不算
644
+ */
645
+ function hasStrictField(schema, top = false) {
464
646
  if (!isRecord(schema))
465
647
  return false;
466
648
  return Object.values(schema).some(entry => {
467
- if (!isRecord(entry))
649
+ if (!isRecord(entry) || (top && isSessionField(entry)))
468
650
  return false;
469
651
  if (entry.onInvalid === 'error')
470
652
  return true;
@@ -482,6 +664,9 @@ function parseEntries(schema, source, prefix, warn) {
482
664
  for (const [key, entry] of Object.entries(entries)) {
483
665
  if (!isRecord(entry) || isUnsafeConfigKey(key))
484
666
  continue;
667
+ // 顶层(prefix 为空)带 session 的字段不交出:插件经会话设置的读取器读它。键仍在 schema 里,配置里有它也不告警
668
+ if (prefix === '' && isSessionField(entry))
669
+ continue;
485
670
  const path = prefix + key;
486
671
  // 只认自有属性:`toString` 这类键不能从原型上读到值
487
672
  const value = Object.hasOwn(source, key) ? source[key] : undefined;
@@ -507,22 +692,22 @@ function parseGroup(fields, value, path, warn) {
507
692
  source = value;
508
693
  else if (value !== undefined && value !== null) {
509
694
  if (hasStrictField(fields))
510
- throw new UnrecoverableField(path, '期望对象(分组)', false);
695
+ throw new UnrecoverableField(path, t('notGroup'), false);
511
696
  warn(`配置项 ${path} 期望对象(分组),得到 ${describeType(value)},已忽略`);
512
697
  }
513
698
  return parseEntries(fields, source, `${path}.`, warn);
514
699
  }
515
700
  function parseField(field, value, path, warn) {
516
- const neutral = NEUTRAL_FIELD_TYPES.has(field.type);
517
- if (value === undefined || value === null || (value === '' && (field.required || !neutral))) {
701
+ if (isUnset(field, value)) {
518
702
  if (field.default !== undefined)
519
703
  return cloneConfigValue(field.default);
520
704
  if (field.required)
521
- throw new UnrecoverableField(path, '必填字段缺失', true);
705
+ throw new UnrecoverableField(path, t('missing'), true);
522
706
  return undefined;
523
707
  }
524
- if (!neutral)
525
- return cloneConfigValue(value);
708
+ if (field.secret === true && isSecretRef(value)) {
709
+ throw new UnrecoverableField(path, t('secretUnresolved', { key: value.$secret }), false, true);
710
+ }
526
711
  const verdict = judgeField(field, value);
527
712
  if ('invalid' in verdict)
528
713
  return fallBack(field, path, verdict.invalid, warn);
@@ -533,8 +718,11 @@ function parseField(field, value, path, warn) {
533
718
  throw new UnrecoverableField(memberPath, safeInvalidReason(first.reason), false);
534
719
  }
535
720
  for (const d of verdict.dropped)
536
- warn(`配置项 ${path}${d.at} ${d.reason},已忽略`);
537
- 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);
538
726
  }
539
727
  function parseArray(entry, value, path, warn) {
540
728
  let list = value;
@@ -543,8 +731,8 @@ function parseArray(entry, value, path, warn) {
543
731
  }
544
732
  else if (!Array.isArray(value)) {
545
733
  if (hasStrictField(entry.items))
546
- throw new UnrecoverableField(path, '期望数组', false);
547
- list = fallBack(entry, path, `期望数组,得到 ${describeType(value)}`, warn);
734
+ throw new UnrecoverableField(path, t('notArray'), false);
735
+ list = fallBack(entry, path, mismatch('expectedArray', value), warn);
548
736
  }
549
737
  // 默认值同样逐元素解析:元素字段的默认值要补齐;default 本身不是数组(畸形 schema)按未配置处理
550
738
  if (!Array.isArray(list))
@@ -555,7 +743,7 @@ function parseArray(entry, value, path, warn) {
555
743
  const at = `${path}[${i}]`;
556
744
  if (!isRecord(item)) {
557
745
  if (entry.onInvalid === 'error')
558
- throw new UnrecoverableField(at, '期望对象元素', false);
746
+ throw new UnrecoverableField(at, t('notItem'), false);
559
747
  warn(`配置项 ${at} 已忽略:期望对象元素,得到 ${describeType(item)}`);
560
748
  continue;
561
749
  }
@@ -567,9 +755,9 @@ function parseArray(entry, value, path, warn) {
567
755
  catch (err) {
568
756
  if (!(err instanceof UnrecoverableField))
569
757
  throw err;
570
- if (entry.onInvalid === 'error')
758
+ if (entry.onInvalid === 'error' || err.fatal)
571
759
  throw err;
572
- warn(`配置项 ${at} 已忽略:${err.path.slice(at.length + 1)} ${err.reason}`);
760
+ warn(`配置项 ${at} 已忽略:${err.path.slice(at.length + 1)} ${err.reason.source}`);
573
761
  continue;
574
762
  }
575
763
  for (const message of pending)
@@ -582,21 +770,24 @@ function fallBack(entry, path, reason, warn) {
582
770
  if (entry.onInvalid === 'error')
583
771
  throw new UnrecoverableField(path, safeInvalidReason(reason), false);
584
772
  if (entry.default !== undefined) {
585
- warn(`配置项 ${path} ${reason},改用默认值 ${describeDefault(entry.default)}`);
773
+ warn(`配置项 ${path} ${reason.text.source},改用默认值 ${describeDefault(entry.default)}`);
586
774
  return cloneConfigValue(entry.default);
587
775
  }
588
776
  if (entry.required)
589
- throw new UnrecoverableField(path, reason, false);
590
- warn(`配置项 ${path} ${reason},已忽略`);
777
+ throw new UnrecoverableField(path, reason.text, false);
778
+ warn(`配置项 ${path} ${reason.text.source},已忽略`);
591
779
  return undefined;
592
780
  }
593
- /** 错误可说明类型与约束,但不列出用户值、静态选项值或正则文本。 */
594
- function safeInvalidReason(reason) {
595
- if (reason.startsWith('不是可选值'))
596
- return '不是可选值';
597
- if (reason.startsWith('不匹配模式'))
598
- return '不匹配模式';
599
- 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;
600
791
  }
601
792
  function describeDefault(value) {
602
793
  try {
@@ -606,7 +797,7 @@ function describeDefault(value) {
606
797
  return String(value);
607
798
  }
608
799
  }
609
- // 与 packages/core/src/infrastructure/config-values.ts 同一规则(本包零运行时依赖,不能 import core)。
800
+ // 与 packages/core/src/infrastructure/config-values.ts 同一规则(本包不能 import core 的运行时)。
610
801
  // 防漂移:test/architecture/config-copy-parity.test.ts
611
802
  const UNSAFE_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
612
803
  /** 配置层无合法含义、落到原型链的键(`__proto__` / `constructor` / `prototype`);宿主的配置文档按它拦 */