@aalis/schema-config 0.13.1 → 0.15.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
@@ -1,19 +1,84 @@
1
1
  // ============================================================
2
2
  // @aalis/schema-config — 配置 Schema 词汇契约 + 词汇的中立解释
3
3
  //
4
- // 类型部分描述"配置如何呈现为表单"(label / options / textarea …);
5
- // 函数部分是对这套词汇的**中立解释**:默认值派生(defaultsFrom)、只读
6
- // 结构校验(validateConfig)、按 schema 键集裁未知字段(removeExtraFields)。
7
- // 三者都不属于 @aalis/core——core 只把 `PluginMeta.configSchema` 当作
4
+ // 类型部分描述"配置如何呈现为表单"(label / options / textarea …),并由
5
+ // ConfigOf 从同一份 schema 推导配置值类型;
6
+ // 函数部分是对这套词汇的**中立解释**:默认值派生(defaultsFrom)与深合并(deepMergeDefaults)、
7
+ // 只读结构校验(validateConfig)、按 schema 键集裁未知字段(removeExtraFields)、
8
+ // 按 schema 解析插件拿到的配置(parseConfig)。
9
+ // 它们都不属于 @aalis/core——core 只把 `PluginMeta.configSchema` 当作
8
10
  // opaque 数据透传,不解释任何字段。另有插件因配置问题无法激活时抛的错误(configError / missingConfigError)。
9
11
  //
10
12
  // 消费方:
11
- // - 插件:在 `definePlugin({ configSchema })` 的定义对象里声明
13
+ // - 插件:用 defineConfig 声明 schema、放进 `definePlugin({ configSchema })`,apply 里用 parseConfig 读配置
12
14
  // - 渲染宿主(webui-server/client 等):读取并渲染表单
13
15
  // - 宿主政策(@aalis/runtime 的 config-sync):按 schema 裁剪未知字段、校验告警
14
16
  //
15
17
  // 本包零运行时依赖:对 @aalis/core 仅有 type-only 锚点 import(编译后擦除)。
16
18
  // ============================================================
19
+ /** 是不是密钥引用:只有 `$secret` 一个自有键、值为非空字符串的纯对象 */
20
+ export function isSecretRef(value) {
21
+ if (!isRecord(value))
22
+ return false;
23
+ const keys = Object.keys(value);
24
+ return keys.length === 1 && keys[0] === '$secret' && typeof value.$secret === 'string' && value.$secret !== '';
25
+ }
26
+ /**
27
+ * 逐个处理配置里密钥字段上的值(顶层、分组内、数组元素内;值为 undefined 的不处理),不改入参:有改动时返回浅拷贝
28
+ * (没动到的部分与入参共享),没有改动时返回入参本身,调用方要改写结果请先复制。回调返回 undefined 时删掉这个键。
29
+ * 宿主拆出、解析密钥与给呈现方去掉密钥都用它,密钥字段的位置只在这里认。
30
+ */
31
+ export function mapSecretFields(schema, config, fn) {
32
+ const visit = (fields, source, prefix, inArray) => {
33
+ let out = source;
34
+ const put = (key, value) => {
35
+ if (out === source)
36
+ out = { ...source };
37
+ if (value === undefined)
38
+ delete out[key];
39
+ else
40
+ out[key] = value;
41
+ };
42
+ for (const [key, entry] of Object.entries(fields)) {
43
+ if (!isRecord(entry) || isUnsafeConfigKey(key) || !Object.hasOwn(source, key))
44
+ continue;
45
+ const value = source[key];
46
+ if ('fields' in entry) {
47
+ if (isRecord(value) && isRecord(entry.fields)) {
48
+ const next = visit(entry.fields, value, `${prefix}${key}.`, inArray);
49
+ if (next !== value)
50
+ put(key, next);
51
+ }
52
+ }
53
+ else if (entry.type === 'array') {
54
+ if (Array.isArray(value) && isRecord(entry.items)) {
55
+ const items = entry.items;
56
+ const next = value.map(item => (isRecord(item) ? visit(items, item, `${prefix}${key}.`, true) : item));
57
+ if (next.some((item, i) => item !== value[i]))
58
+ put(key, next);
59
+ }
60
+ }
61
+ else if (entry.secret === true && value !== undefined) {
62
+ const next = fn(value, { path: prefix + key, inArray });
63
+ if (next !== value)
64
+ put(key, next);
65
+ }
66
+ }
67
+ return out;
68
+ };
69
+ return visit(isRecord(schema) ? schema : {}, config, '', false);
70
+ }
71
+ /**
72
+ * 声明插件的配置 schema:运行时原样返回传入的对象(不冻结、不加任何属性),类型层保留字面量,
73
+ * 供 {@link ConfigOf} 推出配置值类型。返回值可直接放进 `definePlugin({ configSchema })`。
74
+ *
75
+ * 别给变量标 `ConfigSchema` 类型注解:注解会把字面量拓宽掉,ConfigOf 只能推出宽类型。
76
+ * 推出的类型里各属性是只读的、options 是字面量元组;要在运行时改写 schema(WebUI 读的是这个活对象),
77
+ * 经 `ConfigSchema` 类型的引用改写(如 `const live: ConfigSchema = configSchema`)。
78
+ */
79
+ export function defineConfig(schema) {
80
+ return schema;
81
+ }
17
82
  /**
18
83
  * core 基础设施配置(name / logLevel / slowThresholdMs)的表单描述。
19
84
  *
@@ -24,7 +89,7 @@ export const CORE_CONFIG_SCHEMA = {
24
89
  name: {
25
90
  type: 'string',
26
91
  label: '应用名称',
27
- description: '应用显示名称,用于启动日志和界面展示;装有人设时仪表盘仍显示它,聊天显示人设名',
92
+ description: '应用显示名称,用于启动日志和界面展示',
28
93
  default: 'Aalis',
29
94
  },
30
95
  logLevel: {
@@ -48,7 +113,7 @@ export const CORE_CONFIG_SCHEMA = {
48
113
  },
49
114
  };
50
115
  /**
51
- * 从 ConfigSchema 派生默认配置。
116
+ * 从 ConfigSchema 派生默认配置(密钥字段除外)。
52
117
  *
53
118
  * ConfigSchema 是插件配置的**唯一声明来源**:每个字段的 `default` 就是运行时默认值,
54
119
  * 不存在第二份手抄的默认值对象。宿主在注册插件前用本函数派生出默认配置并深合并进配置文档,
@@ -58,6 +123,8 @@ export const CORE_CONFIG_SCHEMA = {
58
123
  * - SchemaField / SchemaArray:取 `default`(没写 default 的字段不产出键,
59
124
  * 等同于「该字段无默认值」——读取方自行处理 undefined)
60
125
  * - SchemaGroup:递归 `fields`,总是产出嵌套对象(即使子字段全无默认值)
126
+ * - `secret: true` 的字段不产出:派生结果会被写进配置文档,密钥的默认值不进文档(map 的默认值还会被深合并进
127
+ * 引用对象),插件经 parseConfig 照常补上
61
128
  */
62
129
  export function defaultsFrom(schema) {
63
130
  const out = {};
@@ -70,14 +137,38 @@ export function defaultsFrom(schema) {
70
137
  out[key] = defaultsFrom(entry.fields);
71
138
  continue;
72
139
  }
140
+ if (entry.secret === true)
141
+ continue;
73
142
  if ('default' in entry)
74
143
  out[key] = cloneConfigValue(entry.default);
75
144
  }
76
145
  return out;
77
146
  }
78
- // 校验器在运行时需要一份"本包内置类型"名单:SchemaFieldTypes 是纯类型层的
147
+ /**
148
+ * 把默认值(通常是 {@link defaultsFrom} 的结果)深合并进配置:只填充缺失的键,已有的值(含显式 null)不覆盖。
149
+ * 嵌套对象递归合并,只写了半块的分组照样补齐其余默认子键;数组与基础类型按「已存在则保留」处理。
150
+ * 宿主的配置同步与 WebUI 保存插件配置用同一个合并,两条路径写出的配置形状一致。
151
+ */
152
+ export function deepMergeDefaults(defaults, current) {
153
+ const result = { ...current };
154
+ for (const [key, defaultValue] of Object.entries(defaults)) {
155
+ if (!Object.hasOwn(result, key)) {
156
+ result[key] = defaultValue;
157
+ }
158
+ else if (defaultValue !== null &&
159
+ typeof defaultValue === 'object' &&
160
+ !Array.isArray(defaultValue) &&
161
+ result[key] !== null &&
162
+ typeof result[key] === 'object' &&
163
+ !Array.isArray(result[key])) {
164
+ result[key] = deepMergeDefaults(defaultValue, result[key]);
165
+ }
166
+ }
167
+ return result;
168
+ }
169
+ // 校验与解析在运行时需要一份"本包内置类型"名单:SchemaFieldTypes 是纯类型层的
79
170
  // merging 注册表,运行时不存在。外来类型(如 'llm-ref')落在名单之外,
80
- // 校验器一律跳过放行——开放词汇表要求校验器同样开放。
171
+ // validateConfig 一律跳过放行、parseConfig 原样透传——开放词汇表要求两者同样开放。
81
172
  const NEUTRAL_FIELD_TYPES = new Set([
82
173
  'string',
83
174
  'number',
@@ -88,47 +179,96 @@ const NEUTRAL_FIELD_TYPES = new Set([
88
179
  'list',
89
180
  'map',
90
181
  ]);
182
+ /** 字段类型是不是本包内置的:内置类型的取值经 validateConfig 与 parseConfig 校验,外来类型(如 'llm-ref')的不经 */
183
+ export function isBuiltinFieldType(type) {
184
+ return NEUTRAL_FIELD_TYPES.has(type);
185
+ }
91
186
  /**
92
187
  * 按 ConfigSchema 对配置做只读结构校验,返回问题清单(空数组 = 无问题)。
93
188
  *
94
189
  * 定位与边界:
95
- * - **只读**:不转换、不回填、不裁剪——回填是 defaultsFrom,裁剪是 removeExtraFields。
96
- * 校验器出错的最大代价必须始终是"少一条警告",因此它绝不参与配置取值链路。
97
- * - **只解释中立词汇**:type/required 之外的语义(含宿主经 declaration merging
98
- * 注入的呈现属性,如 secret/allowCustom)一概不解读。
99
- * - **options 不作为取值白名单**:中立契约里 options 只是"静态选项"(呈现语义),
100
- * 是否允许选项外的值由宿主属性(allowCustom)决定,而本包看不见宿主属性,
101
- * 故 select/multiselect 只查原始类型不查成员资格。
190
+ * - **只读**:不转换、不回填、不裁剪——回填是 defaultsFrom,裁剪是 removeExtraFields,取值是 parseConfig。
191
+ * 逐字段判定与 parseConfig 共用同一份:这里报 invalid 的值,parseConfig 会回落、忽略或拒绝。
192
+ * 校验器出错的最大代价必须始终是"少一条警告",因此它对畸形 schema 不抛错。
193
+ * - **只解释本包的词汇**:type/required、约束键、影响取值的 dynamicOptions/allowCustom 与 secret;
194
+ * 宿主经 declaration merging 注入的呈现属性一概不解读。
195
+ * - **校验的是文档**:`secret` 字段上的 {@link SecretRef} 算合法(值在宿主的密钥存储里),不查引用的键在不在。
196
+ * - **标量宽容**:期望字符串的位置(string/textarea、list 元素、map 值、选项比较)接受有限数字;
197
+ * 期望数字的位置接受去掉首尾空白后能完整解析为有限数的字符串。boolean 不做转换。
198
+ * - **options 即取值范围**:select 有静态 options、没有 dynamicOptions 且 allowCustom 不为 true 时,值须是
199
+ * 某个选项值(按字符串比较);multiselect 同样逐元素判定。
102
200
  * - **约束键**:number 强制 min/max(含边界)与 integer,string/textarea 强制
103
- * pattern(模式非法则跳过该检查);step 是纯 UI 提示不校验。约束只在类型
201
+ * pattern(模式非法则跳过该检查)与 format(空串与只有空白不查);step 是纯 UI 提示不校验。约束只在类型
104
202
  * 检查通过后评估——类型都不对时报类型错,不叠报约束错。
105
- * - **list / map**:list 须为数组且每个元素是 string(保序、重复合法);map 须为
106
- * 对象且每个值是 string,逐值报错的路径形如 `env.KEY`。
107
- * - **宽容缺失**:undefined 与 null(YAML 裸键)视为"未配置",仅在 required 且
108
- * 未声明 default 时报缺——顶层/分组的默认值在调用点已合并,数组元素的默认值
203
+ * - **list / map**:list 须为数组、map 须为对象,逐元素 / 逐值报错,路径形如 `args[1]`、`env.KEY`。
204
+ * - **宽容缺失**:undefined 与 null(YAML 裸键)视为"未配置",required 字段的 `''` 也算未配置(WebUI 与脚手架
205
+ * 用 `''` 表示未填,与 parseConfig 一致;声明了 format 的只有空白也算;空数组仍算已配置),仅在 required 且未声明 default 时报缺——顶层/分组的默认值在调用点已合并,数组元素的默认值
109
206
  * 不参与合并(defaultsFrom 把 SchemaArray 当叶子),靠 default 声明本身放行。
110
207
  *
111
- * 政策留给调用方:config-sync 对启用插件打 warn(不拒载,存量脏值不打死实例;
112
- * 禁用插件的配置是休眠数据,不告警),webui 的 PUT 只拦**新增**的 `invalid`
113
- * (存量问题与 `missing` 放行——拦新不追旧,半成品配置允许落盘)。
208
+ * 政策留给调用方:config-sync 对启用插件打 warn(禁用插件的配置是休眠数据,不告警),
209
+ * webui 的 PUT 只拦**新增**的 `invalid`(存量问题与 `missing` 放行——拦新不追旧,半成品配置允许落盘)。
114
210
  */
115
211
  export function validateConfig(schema, config) {
116
212
  const issues = [];
117
213
  validateFields(schema ?? {}, config, '', issues);
118
214
  return issues;
119
215
  }
216
+ /**
217
+ * 按 schema 解析插件拿到的配置,返回 {@link ConfigOf} 类型的新对象(不改入参)。插件在 apply 里用它读配置:
218
+ * `const cfg = parseConfig(configSchema, caps.config, caps.logger)`,之后的夹紧、跨字段约束等核对作用于 cfg。
219
+ *
220
+ * - **缺失**:undefined 与 null;required 字段另把 `''` 当作缺失(WebUI 与脚手架用 `''` 表示未填;声明了 format 的
221
+ * 只有空白也算),外来类型的 `''` 一律当作缺失。有 default 用 default(拷贝),无 default 且 required 不可恢复,否则省略该键。
222
+ * - **无效**:逐字段判定与 validateConfig 共用(标量宽容、选项即取值范围、约束键)。
223
+ * 默认有 default 回落并告警;无 default 且 required 不可恢复;否则省略并告警。
224
+ * `onInvalid: 'error'` 的字段遇到显式无效值或坏集合成员时,即使有 default 也不可恢复。
225
+ * - **外来类型**:除 `''` 外原样透传,不校验。
226
+ * - **密钥字段**:宿主交给插件之前已把引用解析成原值;`secret` 字段上仍是 {@link SecretRef},说明引用的键不在
227
+ * 宿主的密钥存储里,一律不可恢复(不回落 default,数组元素里的也不只丢这一条),消息点名键名。owner 写了引用,
228
+ * 引用落空就是配置错误,回落默认值会悄悄连上别的东西(例如默认的本机数据库)。
229
+ * - **list / map / multiselect**:默认坏元素、坏值逐个丢弃并告警;严格字段拒绝整个值。
230
+ * - **分组**:值不是对象时默认告警并按空对象解析;含严格字段时拒绝显式无效的分组值。
231
+ * - **数组**:值不是数组按无效处理,含严格元素字段时拒绝显式无效的数组值;元素不是对象、或元素内有不可恢复的字段时只丢这一条元素并告警;
232
+ * 数组自身声明 `onInvalid: 'error'` 时不丢元素而是拒绝整个数组。元素字段的默认值逐元素补齐。
233
+ * - **schema 外的键**:丢弃并告警。
234
+ * - **不可恢复**(顶层或分组内):缺失抛 {@link missingConfigError},无效抛 {@link configError},插件随之进 error 态。
235
+ *
236
+ * 告警只写路径、类型名与约束,不写原值(可能是密钥);默认值可以写。
237
+ */
238
+ export function parseConfig(schema, raw, logger) {
239
+ const warn = (message) => logger?.warn(message);
240
+ let source = {};
241
+ if (isRecord(raw))
242
+ source = raw;
243
+ else if (raw !== undefined && raw !== null) {
244
+ if (hasStrictField(schema))
245
+ throw configError('配置整体期望对象,在 WebUI 或配置文件中改正后生效');
246
+ warn(`配置整体期望对象,得到 ${describeType(raw)},已忽略`);
247
+ }
248
+ try {
249
+ return parseEntries(schema, source, '', warn);
250
+ }
251
+ catch (err) {
252
+ if (!(err instanceof UnrecoverableField))
253
+ throw err;
254
+ throw err.missing
255
+ ? missingConfigError(err.path)
256
+ : configError(`配置项 ${err.path} ${err.reason},在 WebUI 或配置文件中改正后生效`);
257
+ }
258
+ }
120
259
  /**
121
260
  * 按 schema 键集裁掉未知字段。configSchema 是插件配置的**唯一声明来源**(默认值也从它派生),
122
261
  * 所以它的键集就是完整的白名单:不在 schema 里的字段,要么是用户手写的错别字,
123
262
  * 要么是已废弃的旧字段,裁掉即归位。无 schema 的插件不裁(见调用方守卫)。
124
263
  *
125
264
  * 被裁的键写入 `removed`(带点号前缀)——静默裁剪会让「字段被吃掉」与「用户没配」不可分辨。
126
- * `type: 'array'` 的值整段保留(数组元素结构由 validateConfig 解释,不在这里拆)。
265
+ * `type: 'array'` 的值整段保留(数组元素结构由 validateConfig / parseConfig 解释,不在这里拆)。
127
266
  */
128
267
  export function removeExtraFields(config, schema, removed, prefix = '') {
129
268
  const result = {};
130
269
  for (const [key, value] of Object.entries(config)) {
131
- if (!(key in schema)) {
270
+ // 只认 schema 的自有键:配置里的 toString、valueOf 这类键不能靠原型上的同名属性逃过裁剪
271
+ if (!Object.hasOwn(schema, key)) {
132
272
  removed?.push(prefix + key);
133
273
  continue;
134
274
  }
@@ -180,7 +320,7 @@ function validateFields(schema, config, prefix, issues) {
180
320
  if ('fields' in entry) {
181
321
  if (value === undefined || value === null)
182
322
  continue;
183
- if (typeof value !== 'object' || Array.isArray(value)) {
323
+ if (!isRecord(value)) {
184
324
  issues.push({ path, message: `期望对象(分组),得到 ${describeType(value)}`, kind: 'invalid' });
185
325
  continue;
186
326
  }
@@ -195,7 +335,7 @@ function validateFields(schema, config, prefix, issues) {
195
335
  continue;
196
336
  }
197
337
  value.forEach((item, i) => {
198
- if (item === null || typeof item !== 'object' || Array.isArray(item)) {
338
+ if (!isRecord(item)) {
199
339
  issues.push({ path: `${path}[${i}]`, message: `期望对象元素,得到 ${describeType(item)}`, kind: 'invalid' });
200
340
  return;
201
341
  }
@@ -203,7 +343,7 @@ function validateFields(schema, config, prefix, issues) {
203
343
  });
204
344
  continue;
205
345
  }
206
- if (value === undefined || value === null) {
346
+ if (value === undefined || value === null || (entry.required && isBlank(entry, value))) {
207
347
  // 声明了 default 的字段永远不算缺失:顶层/分组的默认值多已在调用点合并,但合并不覆盖
208
348
  // 显式 null(YAML 裸键),这类值照样走到这里,
209
349
  // 数组元素的默认值不参与任何合并(defaultsFrom 把 SchemaArray 当叶子),
@@ -212,101 +352,185 @@ function validateFields(schema, config, prefix, issues) {
212
352
  issues.push({ path, message: '必填字段缺失', kind: 'missing' });
213
353
  continue;
214
354
  }
355
+ if (entry.secret === true && isSecretRef(value))
356
+ continue;
215
357
  if (!NEUTRAL_FIELD_TYPES.has(entry.type))
216
358
  continue;
217
- switch (entry.type) {
218
- case 'string':
219
- case 'textarea':
220
- if (typeof value !== 'string') {
221
- issues.push({ path, message: `期望 string,得到 ${describeType(value)}`, kind: 'invalid' });
222
- }
223
- else if (entry.pattern !== undefined) {
224
- // 模式本身非法(作者的 schema bug)时跳过该检查——校验器不得因 schema
225
- // 缺陷抛错或误伤值;作者侧问题由类型检查与测试兜。
226
- try {
227
- if (!new RegExp(entry.pattern).test(value)) {
228
- issues.push({ path, message: `不匹配模式 ${entry.pattern}`, kind: 'invalid' });
229
- }
230
- }
231
- catch {
232
- /* 非法 pattern:跳过 */
233
- }
234
- }
235
- break;
236
- case 'number':
237
- if (typeof value !== 'number' || !Number.isFinite(value)) {
238
- issues.push({ path, message: `期望有限数值,得到 ${describeType(value)}`, kind: 'invalid' });
239
- }
240
- else if (entry.integer && !Number.isInteger(value)) {
241
- issues.push({ path, message: '期望整数', kind: 'invalid' });
242
- }
243
- else if (entry.min !== undefined && value < entry.min) {
244
- issues.push({ path, message: `小于下限 ${entry.min}`, kind: 'invalid' });
245
- }
246
- else if (entry.max !== undefined && value > entry.max) {
247
- issues.push({ path, message: `大于上限 ${entry.max}`, kind: 'invalid' });
248
- }
249
- break;
250
- case 'boolean':
251
- if (typeof value !== 'boolean')
252
- issues.push({ path, message: `期望 boolean,得到 ${describeType(value)}`, kind: 'invalid' });
253
- break;
254
- case 'select':
255
- if (typeof value !== 'string' && typeof value !== 'number') {
256
- issues.push({ path, message: `期望 string 或 number,得到 ${describeType(value)}`, kind: 'invalid' });
257
- }
258
- break;
259
- case 'multiselect':
260
- if (!Array.isArray(value)) {
261
- issues.push({ path, message: `期望数组,得到 ${describeType(value)}`, kind: 'invalid' });
262
- }
263
- else {
264
- value.forEach((item, i) => {
265
- if (typeof item !== 'string' && typeof item !== 'number') {
266
- issues.push({
267
- path: `${path}[${i}]`,
268
- message: `期望 string 或 number 元素,得到 ${describeType(item)}`,
269
- kind: 'invalid',
270
- });
271
- }
272
- });
273
- }
274
- break;
275
- case 'list':
276
- if (!Array.isArray(value)) {
277
- issues.push({ path, message: `期望数组,得到 ${describeType(value)}`, kind: 'invalid' });
278
- }
279
- else {
280
- value.forEach((item, i) => {
281
- if (typeof item !== 'string') {
282
- issues.push({
283
- path: `${path}[${i}]`,
284
- message: `期望 string 元素,得到 ${describeType(item)}`,
285
- kind: 'invalid',
286
- });
287
- }
288
- });
359
+ const verdict = judgeField(entry, value);
360
+ if ('invalid' in verdict) {
361
+ issues.push({ path, message: verdict.invalid, kind: 'invalid' });
362
+ continue;
363
+ }
364
+ for (const d of verdict.dropped)
365
+ issues.push({ path: path + d.at, message: d.reason, kind: 'invalid' });
366
+ }
367
+ }
368
+ function judgeField(field, value) {
369
+ switch (field.type) {
370
+ case 'string':
371
+ case 'textarea': {
372
+ const text = asText(value);
373
+ if (text === undefined)
374
+ return { invalid: `期望 string,得到 ${describeType(value)}` };
375
+ if (field.pattern !== undefined && !matchesPattern(field.pattern, text)) {
376
+ return { invalid: `不匹配模式 ${field.pattern}` };
377
+ }
378
+ if (field.format === undefined)
379
+ return { value: text, dropped: [] };
380
+ // 路径按去掉首尾空白后判定,交出去的也是这一份:带空白的原值交给插件会被当成相对路径或找不到
381
+ const path = text.trim();
382
+ if (path !== '' && !matchesFormat(field.format, path)) {
383
+ return { invalid: field.format === 'executable' ? '须为绝对路径或命令名(不含路径分隔符)' : '须为绝对路径' };
384
+ }
385
+ return { value: path, dropped: [] };
386
+ }
387
+ case 'number': {
388
+ const num = asNumber(value);
389
+ if (num === undefined)
390
+ return { invalid: `期望有限数值,得到 ${describeType(value)}` };
391
+ 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}` };
397
+ return { value: num, dropped: [] };
398
+ }
399
+ case 'boolean':
400
+ return typeof value === 'boolean'
401
+ ? { value, dropped: [] }
402
+ : { invalid: `期望 boolean,得到 ${describeType(value)}` };
403
+ case 'select': {
404
+ const text = asText(value);
405
+ if (text === undefined)
406
+ return { invalid: `期望 string 或 number,得到 ${describeType(value)}` };
407
+ const options = field.allowCustom === true ? undefined : choiceOptions(field);
408
+ if (!options)
409
+ return { value: text, dropped: [] };
410
+ const hit = options.find(o => String(o.value) === text);
411
+ return hit ? { value: hit.value, dropped: [] } : { invalid: notAnOption(options) };
412
+ }
413
+ case 'multiselect': {
414
+ if (!Array.isArray(value))
415
+ return { invalid: `期望数组,得到 ${describeType(value)}` };
416
+ const options = field.allowCustom === true ? undefined : choiceOptions(field);
417
+ const kept = [];
418
+ const dropped = [];
419
+ for (let i = 0; i < value.length; i++) {
420
+ const item = value[i];
421
+ const text = asText(item);
422
+ if (text === undefined) {
423
+ dropped.push({ at: `[${i}]`, reason: `期望 string 或 number 元素,得到 ${describeType(item)}` });
289
424
  }
290
- break;
291
- case 'map':
292
- if (typeof value !== 'object' || Array.isArray(value)) {
293
- issues.push({ path, message: `期望对象(映射),得到 ${describeType(value)}`, kind: 'invalid' });
425
+ else if (!options) {
426
+ kept.push(text);
294
427
  }
295
428
  else {
296
- for (const [k, v] of Object.entries(value)) {
297
- if (typeof v !== 'string') {
298
- issues.push({
299
- path: `${path}.${k}`,
300
- message: `期望 string 值,得到 ${describeType(v)}`,
301
- kind: 'invalid',
302
- });
303
- }
304
- }
429
+ const hit = options.find(o => String(o.value) === text);
430
+ if (hit)
431
+ kept.push(hit.value);
432
+ else
433
+ dropped.push({ at: `[${i}]`, reason: notAnOption(options) });
305
434
  }
306
- break;
435
+ }
436
+ return { value: kept, dropped };
307
437
  }
438
+ case 'list': {
439
+ if (!Array.isArray(value))
440
+ return { invalid: `期望数组,得到 ${describeType(value)}` };
441
+ const kept = [];
442
+ const dropped = [];
443
+ for (let i = 0; i < value.length; i++) {
444
+ const item = value[i];
445
+ const text = asText(item);
446
+ if (text === undefined)
447
+ dropped.push({ at: `[${i}]`, reason: `期望 string 元素,得到 ${describeType(item)}` });
448
+ else
449
+ kept.push(text);
450
+ }
451
+ return { value: kept, dropped };
452
+ }
453
+ case 'map': {
454
+ if (!isRecord(value))
455
+ return { invalid: `期望对象(映射),得到 ${describeType(value)}` };
456
+ const kept = {};
457
+ const dropped = [];
458
+ for (const [k, v] of Object.entries(value)) {
459
+ const text = asText(v);
460
+ if (text === undefined)
461
+ dropped.push({ at: `.${k}`, reason: `期望 string 值,得到 ${describeType(v)}` });
462
+ // 危险键不进结果对象:写 `__proto__` 会改掉结果的原型
463
+ else if (isUnsafeConfigKey(k))
464
+ dropped.push({ at: `.${k}`, reason: '保留键不可用' });
465
+ else
466
+ kept[k] = text;
467
+ }
468
+ return { value: kept, dropped };
469
+ }
470
+ default:
471
+ return { value, dropped: [] };
472
+ }
473
+ }
474
+ /**
475
+ * 填的是空串(WebUI 与脚手架用 `''` 表示未填)。声明了 format 的 string / textarea 交给插件的是去掉首尾空白的那份
476
+ * (见 judgeField),只有空白同样算空串
477
+ */
478
+ function isBlank(field, value) {
479
+ if (typeof value !== 'string')
480
+ return false;
481
+ const trims = field.format !== undefined && (field.type === 'string' || field.type === 'textarea');
482
+ return (trims ? value.trim() : value) === '';
483
+ }
484
+ /** 期望字符串的位置:字符串原样,有限数字转成字符串(YAML 里不加引号的 QQ 号、端口照常可用) */
485
+ function asText(value) {
486
+ if (typeof value === 'string')
487
+ return value;
488
+ if (typeof value === 'number' && Number.isFinite(value))
489
+ return String(value);
490
+ return undefined;
491
+ }
492
+ /** 期望数字的位置:有限数字原样,去掉首尾空白后非空、能完整解析为有限数的字符串转成数字(YAML 里加了引号的数字照常可用) */
493
+ function asNumber(value) {
494
+ if (typeof value === 'number')
495
+ return Number.isFinite(value) ? value : undefined;
496
+ if (typeof value !== 'string' || value.trim() === '')
497
+ return undefined;
498
+ const num = Number(value);
499
+ return Number.isFinite(num) ? num : undefined;
500
+ }
501
+ /** 构成取值范围的静态选项:有非空 options 且没有 dynamicOptions(动态来源的选项在运行时才知道,静态列表不完整) */
502
+ function choiceOptions(field) {
503
+ if (field.dynamicOptions !== undefined || !Array.isArray(field.options))
504
+ return undefined;
505
+ const options = field.options.filter(o => o !== null && typeof o === 'object');
506
+ return options.length > 0 ? options : undefined;
507
+ }
508
+ function notAnOption(options) {
509
+ return `不是可选值(${options.map(o => JSON.stringify(o.value)).join('、')})之一`;
510
+ }
511
+ function matchesPattern(pattern, text) {
512
+ // 模式本身非法(作者的 schema bug)时跳过该检查——判定不得因 schema
513
+ // 缺陷抛错或误伤值;作者侧问题由类型检查与测试兜。
514
+ try {
515
+ return new RegExp(pattern).test(text);
516
+ }
517
+ catch {
518
+ return true;
308
519
  }
309
520
  }
521
+ /** POSIX 绝对路径、Windows 盘符路径或 UNC 路径 */
522
+ const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
523
+ function matchesFormat(format, text) {
524
+ if (ABSOLUTE_PATH.test(text))
525
+ return true;
526
+ if (format === 'executable')
527
+ return !/[\\/]/.test(text) && !/^[A-Za-z]:/.test(text);
528
+ // 不认识的写法(作者的 schema bug)同 pattern 一样跳过
529
+ return format !== 'absolute-path';
530
+ }
531
+ function isRecord(value) {
532
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
533
+ }
310
534
  function describeType(value) {
311
535
  if (value === null)
312
536
  return 'null';
@@ -316,6 +540,173 @@ function describeType(value) {
316
540
  return String(value);
317
541
  return typeof value;
318
542
  }
543
+ /**
544
+ * 顶层或分组内无法恢复的字段:parseConfig 把它转成 missingConfigError / configError 抛出,数组元素内只丢这一条元素;
545
+ * `fatal` 的(没解析的密钥引用)在数组元素里也让整次解析失败
546
+ */
547
+ class UnrecoverableField extends Error {
548
+ path;
549
+ reason;
550
+ missing;
551
+ fatal;
552
+ constructor(path, reason, missing, fatal = false) {
553
+ super(`${path} ${reason}`);
554
+ this.path = path;
555
+ this.reason = reason;
556
+ this.missing = missing;
557
+ this.fatal = fatal;
558
+ }
559
+ }
560
+ /** 只在祖先值显式无效时检查;缺省的容器仍按字段原有的缺省规则解析。 */
561
+ function hasStrictField(schema) {
562
+ if (!isRecord(schema))
563
+ return false;
564
+ return Object.values(schema).some(entry => {
565
+ if (!isRecord(entry))
566
+ return false;
567
+ if (entry.onInvalid === 'error')
568
+ return true;
569
+ if ('fields' in entry)
570
+ return hasStrictField(entry.fields);
571
+ if (entry.type === 'array')
572
+ return hasStrictField(entry.items);
573
+ return false;
574
+ });
575
+ }
576
+ function parseEntries(schema, source, prefix, warn) {
577
+ // 畸形 schema(分组 fields 为 null、数组缺 items、条目为原始值)按空处理,不抛错
578
+ const entries = isRecord(schema) ? schema : {};
579
+ const out = {};
580
+ for (const [key, entry] of Object.entries(entries)) {
581
+ if (!isRecord(entry) || isUnsafeConfigKey(key))
582
+ continue;
583
+ const path = prefix + key;
584
+ // 只认自有属性:`toString` 这类键不能从原型上读到值
585
+ const value = Object.hasOwn(source, key) ? source[key] : undefined;
586
+ let parsed;
587
+ if ('fields' in entry)
588
+ parsed = parseGroup(entry.fields, value, path, warn);
589
+ else if (entry.type === 'array')
590
+ parsed = parseArray(entry, value, path, warn);
591
+ else
592
+ parsed = parseField(entry, value, path, warn);
593
+ if (parsed !== undefined)
594
+ out[key] = parsed;
595
+ }
596
+ for (const key of Object.keys(source)) {
597
+ if (!Object.hasOwn(entries, key))
598
+ warn(`配置项 ${prefix + key} 不在 schema 中,已忽略`);
599
+ }
600
+ return out;
601
+ }
602
+ function parseGroup(fields, value, path, warn) {
603
+ let source = {};
604
+ if (isRecord(value))
605
+ source = value;
606
+ else if (value !== undefined && value !== null) {
607
+ if (hasStrictField(fields))
608
+ throw new UnrecoverableField(path, '期望对象(分组)', false);
609
+ warn(`配置项 ${path} 期望对象(分组),得到 ${describeType(value)},已忽略`);
610
+ }
611
+ return parseEntries(fields, source, `${path}.`, warn);
612
+ }
613
+ 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)) {
616
+ if (field.default !== undefined)
617
+ return cloneConfigValue(field.default);
618
+ if (field.required)
619
+ throw new UnrecoverableField(path, '必填字段缺失', true);
620
+ return undefined;
621
+ }
622
+ if (field.secret === true && isSecretRef(value)) {
623
+ throw new UnrecoverableField(path, `引用的密钥 ${value.$secret} 不在宿主的密钥存储里`, false, true);
624
+ }
625
+ if (!neutral)
626
+ return cloneConfigValue(value);
627
+ const verdict = judgeField(field, value);
628
+ if ('invalid' in verdict)
629
+ return fallBack(field, path, verdict.invalid, warn);
630
+ if (field.onInvalid === 'error' && verdict.dropped.length > 0) {
631
+ const first = verdict.dropped[0];
632
+ // map 键来自配置,可能本身是密钥;集合下标则可安全指出。
633
+ const memberPath = field.type === 'map' ? path : path + first.at;
634
+ throw new UnrecoverableField(memberPath, safeInvalidReason(first.reason), false);
635
+ }
636
+ for (const d of verdict.dropped)
637
+ warn(`配置项 ${path}${d.at} ${d.reason},已忽略`);
638
+ return verdict.value;
639
+ }
640
+ function parseArray(entry, value, path, warn) {
641
+ let list = value;
642
+ if (value === undefined || value === null) {
643
+ list = entry.default;
644
+ }
645
+ else if (!Array.isArray(value)) {
646
+ if (hasStrictField(entry.items))
647
+ throw new UnrecoverableField(path, '期望数组', false);
648
+ list = fallBack(entry, path, `期望数组,得到 ${describeType(value)}`, warn);
649
+ }
650
+ // 默认值同样逐元素解析:元素字段的默认值要补齐;default 本身不是数组(畸形 schema)按未配置处理
651
+ if (!Array.isArray(list))
652
+ return undefined;
653
+ const out = [];
654
+ for (let i = 0; i < list.length; i++) {
655
+ const item = list[i];
656
+ const at = `${path}[${i}]`;
657
+ if (!isRecord(item)) {
658
+ if (entry.onInvalid === 'error')
659
+ throw new UnrecoverableField(at, '期望对象元素', false);
660
+ warn(`配置项 ${at} 已忽略:期望对象元素,得到 ${describeType(item)}`);
661
+ continue;
662
+ }
663
+ // 元素内的告警先攒着:这条元素整条丢弃时只报丢弃原因
664
+ const pending = [];
665
+ try {
666
+ out.push(parseEntries(entry.items, item, `${at}.`, message => pending.push(message)));
667
+ }
668
+ catch (err) {
669
+ if (!(err instanceof UnrecoverableField))
670
+ throw err;
671
+ if (entry.onInvalid === 'error' || err.fatal)
672
+ throw err;
673
+ warn(`配置项 ${at} 已忽略:${err.path.slice(at.length + 1)} ${err.reason}`);
674
+ continue;
675
+ }
676
+ for (const message of pending)
677
+ warn(message);
678
+ }
679
+ return out;
680
+ }
681
+ /** 无效值的处置:有 default 回落并告警;无 default 且 required 不可恢复;否则省略并告警 */
682
+ function fallBack(entry, path, reason, warn) {
683
+ if (entry.onInvalid === 'error')
684
+ throw new UnrecoverableField(path, safeInvalidReason(reason), false);
685
+ if (entry.default !== undefined) {
686
+ warn(`配置项 ${path} ${reason},改用默认值 ${describeDefault(entry.default)}`);
687
+ return cloneConfigValue(entry.default);
688
+ }
689
+ if (entry.required)
690
+ throw new UnrecoverableField(path, reason, false);
691
+ warn(`配置项 ${path} ${reason},已忽略`);
692
+ return undefined;
693
+ }
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');
701
+ }
702
+ function describeDefault(value) {
703
+ try {
704
+ return JSON.stringify(value) ?? String(value);
705
+ }
706
+ catch {
707
+ return String(value);
708
+ }
709
+ }
319
710
  // 与 packages/core/src/infrastructure/config-values.ts 同一规则(本包零运行时依赖,不能 import core)。
320
711
  // 防漂移:test/architecture/config-copy-parity.test.ts
321
712
  const UNSAFE_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']);