@stofone/dsh-agent-roster 0.0.0-stage → 0.5.1

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.
@@ -0,0 +1,747 @@
1
+ /**
2
+ * 档案语义校验:纯函数、零 IO。
3
+ *
4
+ * 同一份规则同时供运行时(加载档案时)与 CI(`dsh-agent-roster validate --check`)使用,
5
+ * 因此这里不允许读文件、不允许打印、不允许抛异常。
6
+ *
7
+ * 原则:**未知键拒绝**。一个拼错的字段名如果被放过,档案看起来生效了而运行时什么也没做,
8
+ * 这比直接报错危险得多。
9
+ *
10
+ * @module dsh-agent-roster/validate
11
+ */
12
+ import { diagnostic } from "./parse.js";
13
+ import {
14
+ CAPTAIN_FIELDS,
15
+ DEFAULTS_FIELDS,
16
+ FIELD_BINDING,
17
+ LIMITS,
18
+ MEMBER_FIELDS,
19
+ PATTERNS,
20
+ REASONING_EFFORTS,
21
+ ROSTER_FIELDS,
22
+ ROSTER_KIND,
23
+ } from "./format.js";
24
+
25
+ const SINGLE_LINE_FIELDS = new Set(["title", "role", "description", "notes", "license"]);
26
+ const PIPE_SAFE_FIELDS = new Set(["title", "role"]);
27
+ const MAX_TOKENS_CEILING = 4_000_000;
28
+ const MAX_TOOL_PATTERNS = 64;
29
+
30
+ /**
31
+ * 校验一份已解析的档案文档。
32
+ *
33
+ * @param {unknown} value `parse.js` 产出的对象树
34
+ * @param {{
35
+ * file?: string,
36
+ * expectedId?: string,
37
+ * knownToolPolicies?: string[],
38
+ * }} [options]
39
+ * @returns {{ ok: boolean, roster: object | null, diagnostics: object[] }}
40
+ */
41
+ export function validateRoster(value, options = {}) {
42
+ const file = String(options.file ?? "");
43
+ const expectedId = typeof options.expectedId === "string" ? options.expectedId : null;
44
+ const knownToolPolicies = Array.isArray(options.knownToolPolicies)
45
+ ? options.knownToolPolicies.filter((id) => typeof id === "string" && id !== "")
46
+ : null;
47
+ const diagnostics = [];
48
+ const report = (severity, code, path, reason) =>
49
+ diagnostics.push(diagnostic(severity, code, path, reason, file));
50
+
51
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
52
+ report("error", "document-not-mapping", "", "文档顶层必须是映射(对象)");
53
+ return { ok: false, roster: null, diagnostics };
54
+ }
55
+
56
+ rejectUnknownKeys(value, ROSTER_FIELDS, "", "顶层", report);
57
+ guardContent(value, "", report);
58
+
59
+ // ── 标识与版本 ────────────────────────────────────────────────────────────
60
+ if (value.kind !== ROSTER_KIND) {
61
+ report(
62
+ "error",
63
+ "invalid-kind",
64
+ "kind",
65
+ `kind 必须是 "${ROSTER_KIND}",实际是 ${describe(value.kind)}`,
66
+ );
67
+ }
68
+
69
+ const rosterId = value.roster;
70
+ if (typeof rosterId !== "string" || rosterId === "") {
71
+ report("error", "missing-field", "roster", "缺少 roster(档案 id)");
72
+ } else if (!PATTERNS.id.test(rosterId)) {
73
+ report("error", "invalid-id", "roster", `roster 必须是 kebab-case 的 2~64 字符:${rosterId}`);
74
+ } else if (expectedId !== null && rosterId !== expectedId) {
75
+ report(
76
+ "error",
77
+ "roster-id-mismatch",
78
+ "roster",
79
+ `roster 字段(${rosterId})必须与所在目录名(${expectedId})一致`,
80
+ );
81
+ }
82
+
83
+ if (typeof value.version !== "string" || value.version === "") {
84
+ report("error", "missing-field", "version", "缺少 version(精确 SemVer)");
85
+ } else if (!PATTERNS.semver.test(value.version)) {
86
+ report("error", "invalid-semver", "version", `version 必须是精确 SemVer:${value.version}`);
87
+ }
88
+
89
+ const title = optionalSingleLine(value.title, "title", LIMITS.titleChars, report);
90
+ const description = optionalSingleLine(value.description, "description", LIMITS.descriptionChars, report);
91
+ const license = optionalSingleLine(value.license, "license", LIMITS.titleChars, report);
92
+ const maintainers = stringList(value.maintainers, "maintainers", 16, LIMITS.titleChars, report);
93
+ const tags = tagList(value.tags, "tags", report);
94
+
95
+ // ── defaults ─────────────────────────────────────────────────────────────
96
+ const defaults = validateDefaults(value.defaults, report);
97
+
98
+ // ── members ──────────────────────────────────────────────────────────────
99
+ const members = [];
100
+ const rawMembers = value.members;
101
+ if (!Array.isArray(rawMembers) || rawMembers.length === 0) {
102
+ report("error", "missing-field", "members", "members 必须是非空数组");
103
+ } else if (rawMembers.length > LIMITS.members) {
104
+ report("error", "too-many-members", "members", `members 最多 ${LIMITS.members} 个`);
105
+ } else {
106
+ const seen = new Map();
107
+ rawMembers.forEach((raw, index) => {
108
+ const member = validateMember(
109
+ raw,
110
+ defaults,
111
+ `members[${index}]`,
112
+ knownToolPolicies,
113
+ report,
114
+ diagnostics,
115
+ );
116
+ if (member === null) return;
117
+ const previous = seen.get(member.name);
118
+ if (previous !== undefined) {
119
+ report(
120
+ "error",
121
+ "duplicate-member",
122
+ `${at(index)}.name`,
123
+ `成员 name 重复:${member.name}(另见 members[${previous}])`,
124
+ );
125
+ return;
126
+ }
127
+ seen.set(member.name, index);
128
+ members.push(member);
129
+ });
130
+ }
131
+
132
+ const failed = diagnostics.some((item) => item.severity === "error");
133
+ if (failed) return { ok: false, roster: null, diagnostics };
134
+
135
+ const beforeCaptain = diagnostics.length;
136
+ const captain = validateCaptainDocument(value.captain, report);
137
+ if (diagnostics.length > beforeCaptain) {
138
+ return { ok: false, roster: null, diagnostics };
139
+ }
140
+ reportAliasCollisions(members, report);
141
+
142
+ if (diagnostics.some((item) => item.severity === "error")) {
143
+ return { ok: false, roster: null, diagnostics };
144
+ }
145
+
146
+ return {
147
+ ok: true,
148
+ roster: {
149
+ kind: value.kind,
150
+ id: rosterId,
151
+ version: value.version,
152
+ title: title ?? rosterId,
153
+ description: description ?? "",
154
+ license: license ?? "",
155
+ maintainers,
156
+ tags,
157
+ defaults,
158
+ captain,
159
+ members,
160
+ file,
161
+ },
162
+ diagnostics,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * `aliases` 是"成员改过名"的显式记录:核对时旧名会被认作同一个成员。
168
+ * 因此别名既不能撞别人,也不能互相撞——否则核对结果取决于遍历顺序。
169
+ */
170
+ function reportAliasCollisions(members, report) {
171
+ const names = new Map();
172
+ for (const member of members) names.set(member.name, member.name);
173
+ const aliases = new Map();
174
+ members.forEach((member, index) => {
175
+ member.aliases.forEach((alias, aliasIndex) => {
176
+ const path = `members[${index}].aliases[${aliasIndex}]`;
177
+ if (names.has(alias)) {
178
+ report("error", "alias-collision", path, `别名 "${alias}" 与现有成员重名`);
179
+ return;
180
+ }
181
+ const owner = aliases.get(alias);
182
+ if (owner !== undefined) {
183
+ report("error", "alias-collision", path, `别名 "${alias}" 已被成员 "${owner}" 占用`);
184
+ return;
185
+ }
186
+ aliases.set(alias, member.name);
187
+ });
188
+ });
189
+ }
190
+
191
+ /**
192
+ * 校验可选的 captain 段。返回 null 表示未写,false 表示写错,其余是归一化对象。
193
+ *
194
+ * 队长不是成员:agent-teams 里它就是创建团队的那个会话。这里只允许"描述它应该是什么样",
195
+ * 并给出落地提示,绝不假装档案能改写队长的路由。
196
+ */
197
+ function validateCaptainDocument(raw, report) {
198
+ if (raw === undefined) return null;
199
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
200
+ report("error", "invalid-type", "captain", "captain 必须是映射(对象)");
201
+ return null;
202
+ }
203
+ rejectUnknownKeys(raw, CAPTAIN_FIELDS, "captain", "captain", report);
204
+
205
+ const name = raw.name;
206
+ if (typeof name !== "string" || name === "") {
207
+ report("error", "missing-field", "captain.name", "captain 需要 name(给队长一个可引用的名字)");
208
+ } else if (!PATTERNS.id.test(name)) {
209
+ report("error", "invalid-id", "captain.name", `captain.name 必须是 kebab-case 的 2~64 字符:${name}`);
210
+ }
211
+
212
+ const description = optionalSingleLine(raw.description, "captain.description", LIMITS.descriptionChars, report);
213
+ const personaPrefix = optionalText(raw.personaPrefix, "captain.personaPrefix", LIMITS.captainChars, report);
214
+ const systemPrompt = optionalText(raw.systemPrompt, "captain.systemPrompt", LIMITS.systemPromptChars, report);
215
+
216
+ const route = {};
217
+ if (raw.provider !== undefined) {
218
+ if (typeof raw.provider !== "string" || !PATTERNS.provider.test(raw.provider)) {
219
+ report("error", "invalid-provider", "captain.provider", `provider 形态不合法:${describe(raw.provider)}`);
220
+ } else {
221
+ route.provider = raw.provider;
222
+ }
223
+ }
224
+ if (raw.model !== undefined) {
225
+ if (typeof raw.model !== "string" || !PATTERNS.model.test(raw.model)) {
226
+ report("error", "invalid-model", "captain.model", `model 形态不合法:${describe(raw.model)}`);
227
+ } else {
228
+ route.model = raw.model;
229
+ }
230
+ }
231
+ if (raw.reasoningEffort !== undefined) {
232
+ if (!REASONING_EFFORTS.includes(raw.reasoningEffort)) {
233
+ report(
234
+ "error",
235
+ "invalid-reasoning-effort",
236
+ "captain.reasoningEffort",
237
+ `reasoningEffort 只能是 ${REASONING_EFFORTS.join("|")}`,
238
+ );
239
+ } else {
240
+ route.reasoningEffort = raw.reasoningEffort;
241
+ }
242
+ }
243
+ if ((route.provider === undefined) !== (route.model === undefined)) {
244
+ report(
245
+ "error",
246
+ "pair-required",
247
+ route.provider === undefined ? "captain.provider" : "captain.model",
248
+ "captain 的 provider 与 model 必须同时出现",
249
+ );
250
+ }
251
+
252
+ const captain = { name, description: description ?? "", systemPrompt: systemPrompt ?? "", personaPrefix: personaPrefix ?? "" };
253
+ Object.assign(captain, route);
254
+ return captain;
255
+ }
256
+
257
+ /** 多行可选文本(persona/prompt 片段):只卡长度与 NUL,换行是合法的。 */
258
+ function optionalText(raw, path, maxChars, report) {
259
+ if (raw === undefined) return undefined;
260
+ if (typeof raw !== "string") {
261
+ report("error", "invalid-type", path, "必须是字符串");
262
+ return undefined;
263
+ }
264
+ const value = raw.replace(/\r\n/g, "\n").trim();
265
+ if (value === "") {
266
+ report("error", "invalid-value", path, "不允许空字符串(不需要这个字段就整个省略)");
267
+ return undefined;
268
+ }
269
+ if (value.length > maxChars) {
270
+ report("error", "string-too-long", path, `${value.length} 字符,超过上限 ${maxChars}`);
271
+ return undefined;
272
+ }
273
+ return value;
274
+ }
275
+
276
+ /** 校验 defaults 段;返回只含合法字段的归一化对象。 */
277
+ function validateDefaults(raw, report) {
278
+ const defaults = {};
279
+ if (raw === undefined || raw === null) return defaults;
280
+ if (typeof raw !== "object" || Array.isArray(raw)) {
281
+ report("error", "invalid-type", "defaults", "defaults 必须是映射(对象)");
282
+ return defaults;
283
+ }
284
+ rejectUnknownKeys(raw, DEFAULTS_FIELDS, "defaults", "defaults", report);
285
+ const route = validateRoute(raw, "defaults", report);
286
+ if (route.provider !== undefined) defaults.provider = route.provider;
287
+ if (route.model !== undefined) defaults.model = route.model;
288
+ if (route.reasoningEffort !== undefined) defaults.reasoningEffort = route.reasoningEffort;
289
+ if (route.toolPolicy !== undefined) defaults.toolPolicy = route.toolPolicy;
290
+ if (route.toolScope !== undefined) defaults.toolScope = route.toolScope;
291
+ if (route.fallback !== undefined) defaults.fallback = route.fallback;
292
+ return defaults;
293
+ }
294
+
295
+ /** 校验一个成员;返回归一化成员或 null(已产生 error 时)。 */
296
+ function validateMember(raw, defaults, path, knownToolPolicies, report, diagnostics) {
297
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
298
+ report("error", "invalid-type", path, "成员必须是映射(对象)");
299
+ return null;
300
+ }
301
+ const before = diagnostics.length;
302
+ rejectUnknownKeys(raw, MEMBER_FIELDS, path, "成员", report);
303
+
304
+ const name = raw.name;
305
+ if (typeof name !== "string" || name === "") {
306
+ report("error", "missing-field", `${path}.name`, "缺少 name(成员的运行时唯一名)");
307
+ } else if (!PATTERNS.id.test(name)) {
308
+ report("error", "invalid-id", `${path}.name`, `name 必须是 kebab-case 的 2~64 字符:${name}`);
309
+ }
310
+
311
+ const role = requiredSingleLine(raw.role, `${path}.role`, LIMITS.roleChars, report);
312
+
313
+ const systemPrompt = raw.systemPrompt;
314
+ if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
315
+ report("error", "missing-field", `${path}.systemPrompt`, "缺少 systemPrompt(成员人设)");
316
+ } else if (systemPrompt.length > LIMITS.systemPromptChars) {
317
+ report(
318
+ "error",
319
+ "string-too-long",
320
+ `${path}.systemPrompt`,
321
+ `systemPrompt 共 ${systemPrompt.length} 字符,超过上限 ${LIMITS.systemPromptChars}`,
322
+ );
323
+ } else if (/\n\s*````/.test(systemPrompt)) {
324
+ report(
325
+ "error",
326
+ "fence-injection",
327
+ `${path}.systemPrompt`,
328
+ "systemPrompt 含 4 个及以上反引号的围栏行,会破坏下游提示结构",
329
+ );
330
+ }
331
+
332
+ const route = validateRoute(raw, path, report);
333
+ const inherited = [];
334
+ const merged = {};
335
+ for (const key of ["provider", "model", "reasoningEffort", "toolPolicy", "toolScope", "fallback"]) {
336
+ if (route[key] !== undefined) {
337
+ merged[key] = route[key];
338
+ } else if (defaults[key] !== undefined) {
339
+ merged[key] = defaults[key];
340
+ inherited.push(key);
341
+ }
342
+ }
343
+
344
+ // provider / model 必须成对:只给 provider 时 agent-teams 会退回队长模型,
345
+ // 档案的意图就静默丢失了,所以这里直接拒绝。
346
+ const hasProvider = merged.provider !== undefined;
347
+ const hasModel = merged.model !== undefined;
348
+ if (hasProvider !== hasModel) {
349
+ report(
350
+ "error",
351
+ "pair-required",
352
+ hasProvider ? `${path}.model` : `${path}.provider`,
353
+ "provider 与 model 必须同时出现;缺一个会让运行时静默沿用队长模型",
354
+ );
355
+ }
356
+
357
+ const toolPolicy = merged.toolPolicy;
358
+ if (
359
+ typeof toolPolicy === "string" &&
360
+ knownToolPolicies !== null &&
361
+ knownToolPolicies.length > 0 &&
362
+ !knownToolPolicies.includes(toolPolicy)
363
+ ) {
364
+ report(
365
+ "error",
366
+ "unknown-tool-policy",
367
+ `${path}.toolPolicy`,
368
+ `toolPolicy "toolPolicy=${toolPolicy}" 不在部署已声明的策略里(已知:${knownToolPolicies.join(", ")});` +
369
+ "运行时只接受已声明 id,请改用已存在的策略或把它写进 agent-teams 的 toolPolicies 配置",
370
+ );
371
+ }
372
+
373
+ const maxTokens = optionalMaxTokens(raw.maxTokens, `${path}.maxTokens`, report);
374
+ const title = optionalSingleLine(raw.title, `${path}.title`, LIMITS.titleChars, report);
375
+ const notes = optionalSingleLine(raw.notes, `${path}.notes`, LIMITS.notesChars, report);
376
+ const description = optionalSingleLine(raw.description, `${path}.description`, LIMITS.descriptionChars, report);
377
+ const tags = tagList(raw.tags, `${path}.tags`, report);
378
+ const aliases = idList(raw.aliases, `${path}.aliases`, LIMITS.aliasesPerMember, report);
379
+ const skills = skillList(raw.skills, `${path}.skills`, LIMITS.skillsPerMember, report);
380
+ const examples = textList(raw.examples, `${path}.examples`, LIMITS.examplesPerMember, LIMITS.exampleChars, report);
381
+
382
+ if (typeof name === "string" && aliases.includes(name)) {
383
+ report("error", "alias-collision", `${path}.aliases`, `别名不允许包含成员自己的名字:${name}`);
384
+ }
385
+
386
+ if (before !== diagnostics.length) return null;
387
+
388
+ const member = {
389
+ name,
390
+ role,
391
+ systemPrompt,
392
+ title: title ?? name,
393
+ notes: notes ?? "",
394
+ description: description ?? "",
395
+ tags,
396
+ aliases,
397
+ skills,
398
+ examples,
399
+ };
400
+ for (const key of ["provider", "model", "reasoningEffort", "toolPolicy", "toolScope", "fallback"]) {
401
+ if (merged[key] !== undefined) member[key] = merged[key];
402
+ }
403
+ if (maxTokens !== undefined) member.maxTokens = maxTokens;
404
+ if (inherited.length > 0) member.inherited = inherited;
405
+ return member;
406
+ }
407
+
408
+ /** 校验 provider/model/reasoningEffort/toolPolicy/toolScope/fallback 这一组路由字段。 */
409
+ function validateRoute(raw, path, report) {
410
+ const out = {};
411
+ const p = (key) => (path === "" ? key : `${path}.${key}`);
412
+
413
+ if (raw.provider !== undefined) {
414
+ if (typeof raw.provider !== "string" || !PATTERNS.provider.test(raw.provider)) {
415
+ report("error", "invalid-provider", p("provider"), `provider 形态不合法:${describe(raw.provider)}`);
416
+ } else {
417
+ out.provider = raw.provider;
418
+ }
419
+ }
420
+ if (raw.model !== undefined) {
421
+ if (typeof raw.model !== "string" || !PATTERNS.model.test(raw.model)) {
422
+ report("error", "invalid-model", p("model"), `model 形态不合法:${describe(raw.model)}`);
423
+ } else {
424
+ out.model = raw.model;
425
+ }
426
+ }
427
+ if (raw.reasoningEffort !== undefined) {
428
+ if (!REASONING_EFFORTS.includes(raw.reasoningEffort)) {
429
+ report(
430
+ "error",
431
+ "invalid-reasoning-effort",
432
+ p("reasoningEffort"),
433
+ `reasoningEffort 只能是 ${REASONING_EFFORTS.join("|")},实际是 ${describe(raw.reasoningEffort)}`,
434
+ );
435
+ } else {
436
+ out.reasoningEffort = raw.reasoningEffort;
437
+ }
438
+ }
439
+ if (raw.toolPolicy !== undefined) {
440
+ if (typeof raw.toolPolicy !== "string" || !PATTERNS.id.test(raw.toolPolicy)) {
441
+ report("error", "invalid-tool-policy-id", p("toolPolicy"), `toolPolicy 必须是策略 id:${describe(raw.toolPolicy)}`);
442
+ } else {
443
+ out.toolPolicy = raw.toolPolicy;
444
+ }
445
+ }
446
+ if (raw.toolScope !== undefined) {
447
+ const scope = validateToolScope(raw.toolScope, p("toolScope"), report);
448
+ if (scope !== null) out.toolScope = scope;
449
+ }
450
+ if (raw.fallback !== undefined) {
451
+ const fallback = validateFallback(raw.fallback, p("fallback"), report);
452
+ if (fallback !== null) out.fallback = fallback;
453
+ }
454
+ return out;
455
+ }
456
+
457
+ /** `{allow?: string[], deny?: string[]}`:声明层工具范围。 */
458
+ function validateToolScope(raw, path, report) {
459
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
460
+ report("error", "invalid-type", path, "toolScope 必须是映射(对象)");
461
+ return null;
462
+ }
463
+ const allowed = ["allow", "deny"];
464
+ let bad = false;
465
+ for (const key of Object.keys(raw)) {
466
+ if (!allowed.includes(key)) {
467
+ report("error", "unknown-key", `${path}.${key}`, `toolScope 只接受 allow 与 deny,收到 ${key}`);
468
+ bad = true;
469
+ }
470
+ }
471
+ const out = {};
472
+ for (const key of allowed) {
473
+ if (raw[key] === undefined) continue;
474
+ if (!Array.isArray(raw[key])) {
475
+ report("error", "invalid-type", `${path}.${key}`, `toolScope.${key} 必须是字符串数组`);
476
+ bad = true;
477
+ continue;
478
+ }
479
+ if (raw[key].length > MAX_TOOL_PATTERNS) {
480
+ report("error", "too-many-values", `${path}.${key}`, `toolScope.${key} 最多 ${MAX_TOOL_PATTERNS} 项`);
481
+ bad = true;
482
+ continue;
483
+ }
484
+ const values = [];
485
+ raw[key].forEach((item, index) => {
486
+ if (typeof item !== "string" || !PATTERNS.toolPattern.test(item)) {
487
+ report("error", "invalid-tool-name", `${path}.${key}[${index}]`, `工具名不合法:${describe(item)}`);
488
+ bad = true;
489
+ return;
490
+ }
491
+ values.push(item);
492
+ });
493
+ out[key] = values;
494
+ }
495
+ if (!bad && (out.allow?.length ?? 0) === 0 && (out.deny?.length ?? 0) === 0) {
496
+ report("warn", "empty-tool-scope", path, "toolScope 既没有 allow 也没有 deny,等于没写");
497
+ }
498
+ return bad ? null : out;
499
+ }
500
+
501
+ /** 备用路由:provider 与 model 必须同时给出。 */
502
+ function validateFallback(raw, path, report) {
503
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
504
+ report("error", "invalid-type", path, "fallback 必须是 {provider, model} 对象");
505
+ return null;
506
+ }
507
+ let bad = false;
508
+ for (const key of Object.keys(raw)) {
509
+ if (key !== "provider" && key !== "model") {
510
+ report("error", "unknown-key", `${path}.${key}`, `fallback 只接受 provider 与 model,收到 ${key}`);
511
+ bad = true;
512
+ }
513
+ }
514
+ if (typeof raw.provider !== "string" || !PATTERNS.provider.test(raw.provider)) {
515
+ report("error", "invalid-provider", `${path}.provider`, `fallback.provider 形态不合法:${describe(raw.provider)}`);
516
+ bad = true;
517
+ }
518
+ if (typeof raw.model !== "string" || !PATTERNS.model.test(raw.model)) {
519
+ report("error", "invalid-model", `${path}.model`, `fallback.model 形态不合法:${describe(raw.model)}`);
520
+ bad = true;
521
+ }
522
+ return bad ? null : { provider: raw.provider, model: raw.model };
523
+ }
524
+
525
+ function optionalMaxTokens(raw, path, report) {
526
+ if (raw === undefined) return undefined;
527
+ if (!Number.isInteger(raw) || raw < 1 || raw > MAX_TOKENS_CEILING) {
528
+ report("error", "invalid-number", path, `maxTokens 必须是 1~${MAX_TOKENS_CEILING} 的整数:${describe(raw)}`);
529
+ return undefined;
530
+ }
531
+ return raw;
532
+ }
533
+
534
+ function tagList(raw, path, report) {
535
+ if (raw === undefined) return [];
536
+ if (!Array.isArray(raw)) {
537
+ report("error", "invalid-type", path, "tags 必须是字符串数组");
538
+ return [];
539
+ }
540
+ if (raw.length > LIMITS.tagsPerEntry) {
541
+ report("error", "too-many-values", path, `tags 最多 ${LIMITS.tagsPerEntry} 项`);
542
+ return [];
543
+ }
544
+ const out = [];
545
+ raw.forEach((item, index) => {
546
+ if (typeof item !== "string" || !PATTERNS.tag.test(item)) {
547
+ report("error", "invalid-tag", `${path}[${index}]`, `tag 必须是小写 kebab-case:${describe(item)}`);
548
+ return;
549
+ }
550
+ out.push(item);
551
+ });
552
+ return out;
553
+ }
554
+
555
+ /** kebab-case id 数组(别名用):去重、限数量、不允许空串。 */
556
+ function idList(raw, path, max, report) {
557
+ if (raw === undefined) return [];
558
+ if (!Array.isArray(raw)) {
559
+ report("error", "invalid-type", path, "必须是字符串数组");
560
+ return [];
561
+ }
562
+ if (raw.length > max) {
563
+ report("error", "too-many-values", path, `最多 ${max} 项`);
564
+ return [];
565
+ }
566
+ const out = [];
567
+ raw.forEach((item, index) => {
568
+ if (typeof item !== "string" || !PATTERNS.id.test(item)) {
569
+ report("error", "invalid-id", `${path}[${index}]`, `必须是 kebab-case 的 2~64 字符:${describe(item)}`);
570
+ return;
571
+ }
572
+ if (out.includes(item)) {
573
+ report("error", "duplicate-value", `${path}[${index}]`, `重复项:${item}`);
574
+ return;
575
+ }
576
+ out.push(item);
577
+ });
578
+ return out;
579
+ }
580
+
581
+ /** 技能名数组:与 DSH 技能目录同名,允许点与下划线。 */
582
+ function skillList(raw, path, max, report) {
583
+ if (raw === undefined) return [];
584
+ if (!Array.isArray(raw)) {
585
+ report("error", "invalid-type", path, "必须是字符串数组");
586
+ return [];
587
+ }
588
+ if (raw.length > max) {
589
+ report("error", "too-many-values", path, `最多 ${max} 项`);
590
+ return [];
591
+ }
592
+ const out = [];
593
+ raw.forEach((item, index) => {
594
+ if (typeof item !== "string" || !PATTERNS.skill.test(item)) {
595
+ report("error", "invalid-skill", `${path}[${index}]`, `技能名不合法:${describe(item)}`);
596
+ return;
597
+ }
598
+ if (out.includes(item)) {
599
+ report("error", "duplicate-value", `${path}[${index}]`, `重复项:${item}`);
600
+ return;
601
+ }
602
+ out.push(item);
603
+ });
604
+ return out;
605
+ }
606
+
607
+ /** 单行文本数组(examples 用)。 */
608
+ function textList(raw, path, max, maxChars, report) {
609
+ if (raw === undefined) return [];
610
+ if (!Array.isArray(raw)) {
611
+ report("error", "invalid-type", path, "必须是字符串数组");
612
+ return [];
613
+ }
614
+ if (raw.length > max) {
615
+ report("error", "too-many-values", path, `最多 ${max} 项`);
616
+ return [];
617
+ }
618
+ const out = [];
619
+ raw.forEach((item, index) => {
620
+ if (typeof item !== "string" || item.trim() === "") {
621
+ report("error", "invalid-value", `${path}[${index}]`, "必须是非空字符串");
622
+ return;
623
+ }
624
+ const value = item.trim();
625
+ if (value.length > maxChars) {
626
+ report("error", "string-too-long", `${path}[${index}]`, `${value.length} 字符,超过上限 ${maxChars}`);
627
+ return;
628
+ }
629
+ if (/[\r\n]/.test(value)) {
630
+ report("error", "single-line-required", `${path}[${index}]`, "必须是单行文本");
631
+ return;
632
+ }
633
+ out.push(value);
634
+ });
635
+ return out;
636
+ }
637
+
638
+ function stringList(raw, path, max, maxChars, report) {
639
+ if (raw === undefined) return [];
640
+ if (!Array.isArray(raw)) {
641
+ report("error", "invalid-type", path, "必须是字符串数组");
642
+ return [];
643
+ }
644
+ if (raw.length > max) {
645
+ report("error", "too-many-values", path, `最多 ${max} 项`);
646
+ return [];
647
+ }
648
+ const out = [];
649
+ raw.forEach((item, index) => {
650
+ if (typeof item !== "string" || item.trim() === "" || item.length > maxChars) {
651
+ report("error", "invalid-value", `${path}[${index}]`, `必须是 1~${maxChars} 字符的非空字符串`);
652
+ return;
653
+ }
654
+ out.push(item.trim());
655
+ });
656
+ return out;
657
+ }
658
+
659
+ function requiredSingleLine(raw, path, maxChars, report) {
660
+ if (typeof raw !== "string" || raw.trim() === "") {
661
+ report("error", "missing-field", path, "必填的单行文本");
662
+ return "";
663
+ }
664
+ const value = raw.trim();
665
+ if (value.length > maxChars) {
666
+ report("error", "string-too-long", path, `${value.length} 字符,超过上限 ${maxChars}`);
667
+ return value;
668
+ }
669
+ if (/[\r\n]/.test(value)) {
670
+ report("error", "single-line-required", path, "必须是单行文本");
671
+ }
672
+ if (PIPE_SAFE_FIELDS.has(path.split(".").pop() ?? "") && value.includes("|")) {
673
+ report("error", "reserved-character", path, "不允许出现竖线,会破坏下游表格渲染");
674
+ }
675
+ return value;
676
+ }
677
+
678
+ /** 单行可选文本;换行一律拒绝(下游会把它拼进表格与提示)。 */
679
+ function optionalSingleLine(raw, path, maxChars, report) {
680
+ if (raw === undefined) return undefined;
681
+ if (typeof raw !== "string") {
682
+ report("error", "invalid-type", path, "必须是单行字符串");
683
+ return undefined;
684
+ }
685
+ const value = raw.trim();
686
+ if (value === "") {
687
+ report("error", "invalid-value", path, "不允许空字符串(不需要这个字段就整个省略)");
688
+ return undefined;
689
+ }
690
+ if (value.length > maxChars) {
691
+ report("error", "string-too-long", path, `${value.length} 字符,超过上限 ${maxChars}`);
692
+ return undefined;
693
+ }
694
+ const leaf = path.split(".").pop() ?? "";
695
+ if (SINGLE_LINE_FIELDS.has(leaf) && /[\r\n]/.test(value)) {
696
+ report("error", "single-line-required", path, "必须是单行文本");
697
+ }
698
+ if (PIPE_SAFE_FIELDS.has(leaf) && value.includes("|")) {
699
+ report("error", "reserved-character", path, "不允许出现竖线,会破坏下游表格渲染");
700
+ }
701
+ return value;
702
+ }
703
+
704
+ function rejectUnknownKeys(raw, allowed, path, label, report) {
705
+ for (const key of Object.keys(raw)) {
706
+ if (allowed.includes(key)) continue;
707
+ report(
708
+ "error",
709
+ "unknown-key",
710
+ path === "" ? key : `${path}.${key}`,
711
+ `${label}不接受字段 "${key}"(允许:${allowed.join(", ")})`,
712
+ );
713
+ }
714
+ }
715
+
716
+ /** 内容级护栏:NUL 字节、以及超出 UTF-8 基本面的控制字符。 */
717
+ function guardContent(value, path, report) {
718
+ if (typeof value === "string") {
719
+ if (value.includes("\u0000")) {
720
+ report("error", "forbidden-content", path, "文本不允许包含 NUL 字符");
721
+ }
722
+ return;
723
+ }
724
+ if (Array.isArray(value)) {
725
+ value.forEach((item, index) => guardContent(item, path === "" ? `[${index}]` : `${path}[${index}]`, report));
726
+ return;
727
+ }
728
+ if (value !== null && typeof value === "object") {
729
+ for (const [key, item] of Object.entries(value)) {
730
+ guardContent(item, path === "" ? key : `${path}.${key}`, report);
731
+ }
732
+ }
733
+ }
734
+
735
+ function at(index) {
736
+ return `members[${index}]`;
737
+ }
738
+
739
+ /** 把任意值折成一句可读描述,用于错误消息。 */
740
+ function describe(value) {
741
+ if (typeof value === "string") return JSON.stringify(value);
742
+ if (value === undefined) return "undefined";
743
+ if (value === null) return "null";
744
+ if (Array.isArray(value)) return `数组(${value.length})`;
745
+ if (typeof value === "object") return "对象";
746
+ return String(value);
747
+ }