@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/README.md +48 -15
- package/dist/index.d.ts +192 -38
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +279 -88
- 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,70 @@
|
|
|
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';
|
|
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: '
|
|
119
|
+
label: t('slowThresholdLabel'),
|
|
120
|
+
description: t('slowThresholdDescription'),
|
|
59
121
|
default: 60000,
|
|
60
122
|
min: 0,
|
|
61
123
|
},
|
|
62
124
|
};
|
|
63
125
|
/**
|
|
64
|
-
*
|
|
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
|
|
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
|
|
134
|
-
* 宿主经 declaration merging
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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('
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
377
|
+
push(path, 'invalid', mismatch('expectedArray', value));
|
|
271
378
|
continue;
|
|
272
379
|
}
|
|
273
380
|
value.forEach((item, i) => {
|
|
274
381
|
if (!isRecord(item)) {
|
|
275
|
-
|
|
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 (
|
|
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
|
-
|
|
395
|
+
push(path, 'missing', { code: 'missing', text: t('missing') });
|
|
289
396
|
continue;
|
|
290
397
|
}
|
|
291
|
-
if (
|
|
398
|
+
if (entry.secret === true && isSecretRef(value))
|
|
292
399
|
continue;
|
|
293
400
|
const verdict = judgeField(entry, value);
|
|
294
401
|
if ('invalid' in verdict) {
|
|
295
|
-
|
|
402
|
+
push(path, 'invalid', verdict.invalid);
|
|
296
403
|
continue;
|
|
297
404
|
}
|
|
298
405
|
for (const d of verdict.dropped)
|
|
299
|
-
|
|
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:
|
|
422
|
+
return { invalid: mismatch('expectedString', value) };
|
|
309
423
|
if (field.pattern !== undefined && !matchesPattern(field.pattern, text)) {
|
|
310
|
-
return { invalid:
|
|
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:
|
|
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:
|
|
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:
|
|
322
|
-
|
|
323
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
475
|
+
dropped.push({ at: `[${i}]`, reason: mismatch('expectedChoiceItem', item) });
|
|
351
476
|
}
|
|
352
477
|
else if (!options) {
|
|
353
|
-
|
|
478
|
+
keep(text);
|
|
354
479
|
}
|
|
355
480
|
else {
|
|
356
481
|
const hit = options.find(o => String(o.value) === text);
|
|
357
482
|
if (hit)
|
|
358
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
624
|
+
/**
|
|
625
|
+
* 顶层或分组内无法恢复的字段:parseConfig 把它转成 missingConfigError / configError 抛出,数组元素内只丢这一条元素;
|
|
626
|
+
* `fatal` 的(没解析的密钥引用)在数组元素里也让整次解析失败
|
|
627
|
+
*/
|
|
451
628
|
class UnrecoverableField extends Error {
|
|
452
629
|
path;
|
|
453
630
|
reason;
|
|
454
631
|
missing;
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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, '
|
|
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
|
-
|
|
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, '
|
|
705
|
+
throw new UnrecoverableField(path, t('missing'), true);
|
|
522
706
|
return undefined;
|
|
523
707
|
}
|
|
524
|
-
if (
|
|
525
|
-
|
|
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
|
-
|
|
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, '
|
|
547
|
-
list = fallBack(entry, path,
|
|
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, '
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
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`);宿主的配置文档按它拦 */
|