@lokvis/schema 0.1.0 → 0.2.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/src/validators.ts CHANGED
@@ -16,6 +16,32 @@ export const assetTypeSchema = z.enum([
16
16
  'unknown',
17
17
  ]);
18
18
 
19
+ /** W10: Workflow 分类枚举(与 WorkflowCategory 类型对齐) */
20
+ export const workflowCategorySchema = z.enum([
21
+ 'image',
22
+ 'video',
23
+ 'audio',
24
+ 'pdf',
25
+ 'ai',
26
+ 'data',
27
+ 'developer',
28
+ 'ecommerce',
29
+ 'content-creation',
30
+ 'other',
31
+ ]);
32
+
33
+ /** W10: Workflow 输出类型枚举(含 archive 用于打包下载场景) */
34
+ export const workflowOutputTypeSchema = z.enum([
35
+ 'image',
36
+ 'video',
37
+ 'audio',
38
+ 'pdf',
39
+ 'text',
40
+ 'data',
41
+ 'unknown',
42
+ 'archive',
43
+ ]);
44
+
19
45
  export const assetMetadataSchema = z.object({
20
46
  mimeType: z.string(),
21
47
  size: z.number().nonnegative(),
@@ -51,7 +77,7 @@ export const workflowSchema = z.object({
51
77
  name: z.string(),
52
78
  description: z.string(),
53
79
  author: z.object({ id: z.string(), name: z.string() }),
54
- category: z.string(),
80
+ category: workflowCategorySchema,
55
81
  tags: z.array(z.string()),
56
82
  nodes: z.array(workflowNodeSchema),
57
83
  edges: z.array(workflowEdgeSchema),
@@ -62,7 +88,7 @@ export const workflowSchema = z.object({
62
88
  accept: z.array(z.string()).optional(),
63
89
  }),
64
90
  outputs: z.object({
65
- type: z.enum(['image', 'video', 'audio', 'pdf', 'text', 'data', 'unknown', 'archive']),
91
+ type: workflowOutputTypeSchema,
66
92
  format: z.string().optional(),
67
93
  }),
68
94
  official: z.boolean().optional(),
@@ -83,9 +109,252 @@ export const pluginManifestSchema = z.object({
83
109
  permissions: z.array(z.string()),
84
110
  });
85
111
 
86
- /** 校验 Workflow JSON */
87
- export function validateWorkflow(data: unknown) {
88
- return workflowSchema.safeParse(data);
112
+ /**
113
+ * 校验 Workflow JSON。
114
+ *
115
+ * 修复 review 报告:原实现仅做 Zod 形状校验,不检查 edge 引用、保留字、DAG 合法性,
116
+ * 导致 demo 用 `__input__` 哨兵边时 Zod 通过但 executor 抛 "cycle"。
117
+ * 现增加结构层校验,让错误在入口处暴露。
118
+ *
119
+ * W10.2 增强:新增 capability 兼容性校验(可选)。
120
+ * 通过 `options.resolveCapability` 回调查询 capability 的 inputTypes/outputTypes,
121
+ * 检查相邻节点的输出类型与下一节点的输入类型是否兼容。schema 包无法直接访问
122
+ * CapabilityRegistry,故采用回调注入模式(避免五层依赖违规)。
123
+ *
124
+ * @param data 待校验的 Workflow JSON
125
+ * @param options 可选项:
126
+ * - resolveCapability: (name: string) => Capability | undefined
127
+ * 返回 capability 声明;返回 undefined 时跳过该节点的兼容性校验(向后兼容)
128
+ * - maxSteps: number
129
+ * 最大节点数限制(默认不限制;W10 要求 5 步,调用方按需传入)
130
+ */
131
+ export interface ValidateWorkflowOptions {
132
+ /** 查询 capability 声明的回调(返回 undefined 时跳过该节点校验) */
133
+ resolveCapability?: (name: string) => {
134
+ inputTypes: string[];
135
+ outputTypes: string[];
136
+ } | undefined;
137
+ /** 最大节点数限制(可选) */
138
+ maxSteps?: number;
139
+ }
140
+
141
+ export function validateWorkflow(data: unknown, options?: ValidateWorkflowOptions) {
142
+ const parsed = workflowSchema.safeParse(data);
143
+ if (!parsed.success) {
144
+ return parsed;
145
+ }
146
+
147
+ const wf = parsed.data;
148
+ const errors: string[] = [];
149
+
150
+ // 0. W10.2: 节点数上限校验(可选)
151
+ if (options?.maxSteps !== undefined && wf.nodes.length > options.maxSteps) {
152
+ errors.push(
153
+ `Workflow has ${wf.nodes.length} nodes, exceeds max ${options.maxSteps}.`
154
+ );
155
+ }
156
+
157
+ // 1. 保留字哨兵:禁止 __input__ / __output__ 出现在 nodes 或 edges
158
+ // (executor 不支持哨兵节点,输入资产注入到入度 0 的首节点)
159
+ const RESERVED_IDS = new Set(['__input__', '__output__', '__start__', '__end__']);
160
+ for (const node of wf.nodes) {
161
+ if (RESERVED_IDS.has(node.id)) {
162
+ errors.push(
163
+ `Node id "${node.id}" is reserved; use a non-reserved id.`
164
+ );
165
+ }
166
+ }
167
+ for (const edge of wf.edges) {
168
+ if (RESERVED_IDS.has(edge.from)) {
169
+ errors.push(
170
+ `Edge from "${edge.from}" references a reserved sentinel id; ` +
171
+ `the executor injects input assets into the first in-degree-0 node, ` +
172
+ `no explicit __input__ edge is needed.`
173
+ );
174
+ }
175
+ if (RESERVED_IDS.has(edge.to)) {
176
+ errors.push(
177
+ `Edge to "${edge.to}" references a reserved sentinel id.`
178
+ );
179
+ }
180
+ }
181
+
182
+ // 2. node id 唯一性
183
+ const seenIds = new Set<string>();
184
+ for (const node of wf.nodes) {
185
+ if (seenIds.has(node.id)) {
186
+ errors.push(`Duplicate node id: "${node.id}".`);
187
+ }
188
+ seenIds.add(node.id);
189
+ }
190
+
191
+ // 3. edge.from / edge.to 必须引用已声明节点
192
+ const nodeIds = new Set(wf.nodes.map((n) => n.id));
193
+ for (const edge of wf.edges) {
194
+ if (!nodeIds.has(edge.from)) {
195
+ errors.push(
196
+ `Edge from "${edge.from}" to "${edge.to}" references unknown source node "${edge.from}".`
197
+ );
198
+ }
199
+ if (!nodeIds.has(edge.to)) {
200
+ errors.push(
201
+ `Edge from "${edge.from}" to "${edge.to}" references unknown target node "${edge.to}".`
202
+ );
203
+ }
204
+ if (edge.from === edge.to) {
205
+ errors.push(`Edge from "${edge.from}" to itself is a self-loop (cycle).`);
206
+ }
207
+ }
208
+
209
+ // 4. DAG 合法性:无环(拓扑排序能覆盖所有节点)
210
+ if (errors.length === 0) {
211
+ const inDegree = new Map<string, number>();
212
+ const adjacency = new Map<string, string[]>();
213
+ for (const node of wf.nodes) {
214
+ inDegree.set(node.id, 0);
215
+ adjacency.set(node.id, []);
216
+ }
217
+ for (const edge of wf.edges) {
218
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
219
+ adjacency.get(edge.from)?.push(edge.to);
220
+ }
221
+ const queue: string[] = [];
222
+ for (const [id, deg] of inDegree) {
223
+ if (deg === 0) queue.push(id);
224
+ }
225
+ let visited = 0;
226
+ while (queue.length > 0) {
227
+ const id = queue.shift();
228
+ // 循环条件 queue.length > 0 保证 shift 必返回元素;
229
+ // 显式检查以满足 noUncheckedIndexedAccess,并在不变量被打破时跳出
230
+ if (id === undefined) break;
231
+ visited++;
232
+ for (const next of adjacency.get(id) ?? []) {
233
+ const newDeg = (inDegree.get(next) ?? 0) - 1;
234
+ inDegree.set(next, newDeg);
235
+ if (newDeg === 0) queue.push(next);
236
+ }
237
+ }
238
+ if (visited !== wf.nodes.length) {
239
+ errors.push(
240
+ `Workflow contains a cycle: only ${visited}/${wf.nodes.length} nodes are reachable.`
241
+ );
242
+ }
243
+ }
244
+
245
+ // 5. W10.2: capability 兼容性校验(可选,仅在 resolveCapability 提供时)
246
+ // 检查相邻节点(通过 edge 连接)的 outputTypes 与下一节点的 inputTypes 是否有交集。
247
+ // - 线性链:edge.from → edge.to,from 节点的 outputTypes 与 to 节点的 inputTypes 交集为空则报错
248
+ // - 输入节点(入度 0)的 inputTypes 与 workflow.inputs.type 兼容性
249
+ // - 输出节点(出度 0)的 outputTypes 与 workflow.outputs.type 兼容性
250
+ if (options?.resolveCapability && errors.length === 0) {
251
+ validateCapabilityCompatibility(wf, options.resolveCapability, errors);
252
+ }
253
+
254
+ if (errors.length > 0) {
255
+ return {
256
+ success: false as const,
257
+ error: {
258
+ issues: errors.map((message) => ({
259
+ code: 'custom' as const,
260
+ message,
261
+ path: [],
262
+ })),
263
+ },
264
+ };
265
+ }
266
+
267
+ return { success: true as const, data: wf };
268
+ }
269
+
270
+ /** W10.2: capability 兼容性校验内部实现 */
271
+ function validateCapabilityCompatibility(
272
+ wf: z.infer<typeof workflowSchema>,
273
+ resolveCapability: (name: string) => { inputTypes: string[]; outputTypes: string[] } | undefined,
274
+ errors: string[]
275
+ ): void {
276
+ // 计算每个节点的入度/出度,识别输入/输出节点
277
+ const inDegree = new Map<string, number>();
278
+ const outDegree = new Map<string, number>();
279
+ for (const node of wf.nodes) {
280
+ inDegree.set(node.id, 0);
281
+ outDegree.set(node.id, 0);
282
+ }
283
+ for (const edge of wf.edges) {
284
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
285
+ outDegree.set(edge.from, (outDegree.get(edge.from) ?? 0) + 1);
286
+ }
287
+
288
+ // 缓存每个节点的 capability 声明
289
+ const capCache = new Map<string, { inputTypes: string[]; outputTypes: string[] } | undefined>();
290
+ type InferredNode = z.infer<typeof workflowNodeSchema>;
291
+ const getCap = (node: InferredNode) => {
292
+ if (!node.capability) return undefined;
293
+ if (capCache.has(node.capability)) return capCache.get(node.capability);
294
+ const cap = resolveCapability(node.capability);
295
+ capCache.set(node.capability, cap);
296
+ return cap;
297
+ };
298
+
299
+ // 5a. 输入节点(入度 0)的 inputTypes 与 workflow.inputs.type 兼容
300
+ for (const node of wf.nodes) {
301
+ if ((inDegree.get(node.id) ?? 0) > 0) continue;
302
+ const cap = getCap(node);
303
+ // 未注册的 capability 不静默跳过:在 schema 层显式报错,避免用户得到"校验通过"
304
+ // 的假象,运行时才报错。resolveCapability 未提供时(getCap 始终返回 undefined)
305
+ // 整个 capability 兼容性校验跳过(向后兼容,见 5d 兜底)。
306
+ if (!cap) {
307
+ if (node.capability) {
308
+ errors.push(
309
+ `Node "${node.id}" references unknown capability "${node.capability}". ` +
310
+ `Capability is not registered in the registry.`
311
+ );
312
+ }
313
+ continue;
314
+ }
315
+ const inputTypeMatches = cap.inputTypes.includes(wf.inputs.type);
316
+ if (!inputTypeMatches) {
317
+ errors.push(
318
+ `Node "${node.id}" (capability "${node.capability}") expects input types ` +
319
+ `[${cap.inputTypes.join(', ')}], but workflow input is "${wf.inputs.type}".`
320
+ );
321
+ }
322
+ }
323
+
324
+ // 5b. 相邻节点:from 的 outputTypes 与 to 的 inputTypes 必须有交集
325
+ for (const edge of wf.edges) {
326
+ const fromNode = wf.nodes.find((n) => n.id === edge.from);
327
+ const toNode = wf.nodes.find((n) => n.id === edge.to);
328
+ if (!fromNode || !toNode) continue;
329
+ const fromCap = getCap(fromNode);
330
+ const toCap = getCap(toNode);
331
+ if (!fromCap || !toCap) continue; // 5a 已报告未注册 capability,此处不重复
332
+ const compatible = fromCap.outputTypes.some((t) => toCap.inputTypes.includes(t));
333
+ if (!compatible) {
334
+ errors.push(
335
+ `Capability mismatch on edge "${edge.from}" → "${edge.to}": ` +
336
+ `"${fromNode.capability}" outputs [${fromCap.outputTypes.join(', ')}], ` +
337
+ `but "${toNode.capability}" accepts [${toCap.inputTypes.join(', ')}].`
338
+ );
339
+ }
340
+ }
341
+
342
+ // 5c. 输出节点(出度 0)的 outputTypes 与 workflow.outputs.type 兼容
343
+ // (archive 类型输出允许任意类型,用于打包下载场景)
344
+ for (const node of wf.nodes) {
345
+ if ((outDegree.get(node.id) ?? 0) > 0) continue;
346
+ const cap = getCap(node);
347
+ if (!cap) continue; // 5a 已报告未注册 capability,此处不重复
348
+ const outputType = wf.outputs.type;
349
+ if (outputType === 'archive') continue;
350
+ const outputTypeMatches = cap.outputTypes.includes(outputType);
351
+ if (!outputTypeMatches) {
352
+ errors.push(
353
+ `Node "${node.id}" (capability "${node.capability}") produces output types ` +
354
+ `[${cap.outputTypes.join(', ')}], but workflow output is "${outputType}".`
355
+ );
356
+ }
357
+ }
89
358
  }
90
359
 
91
360
  /** 校验 Plugin Manifest */