@mawaru/sdk 0.5.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.
Files changed (43) hide show
  1. package/_schemas/ai-manifest.test.ts +135 -0
  2. package/_schemas/ai-manifest.ts +76 -0
  3. package/_schemas/graph.test.ts +872 -0
  4. package/_schemas/graph.ts +439 -0
  5. package/_schemas/hook-manifest.test.ts +45 -0
  6. package/_schemas/hook-manifest.ts +25 -0
  7. package/_schemas/node.test.ts +76 -0
  8. package/_schemas/node.ts +54 -0
  9. package/_schemas/port-spec.test.ts +657 -0
  10. package/_schemas/port-spec.ts +405 -0
  11. package/_schemas/program-manifest.test.ts +126 -0
  12. package/_schemas/program-manifest.ts +43 -0
  13. package/dist/_schemas/ai-manifest.d.ts +25 -0
  14. package/dist/_schemas/ai-manifest.js +67 -0
  15. package/dist/_schemas/graph.d.ts +226 -0
  16. package/dist/_schemas/graph.js +372 -0
  17. package/dist/_schemas/hook-manifest.d.ts +18 -0
  18. package/dist/_schemas/hook-manifest.js +21 -0
  19. package/dist/_schemas/node.d.ts +47 -0
  20. package/dist/_schemas/node.js +42 -0
  21. package/dist/_schemas/port-spec.d.ts +62 -0
  22. package/dist/_schemas/port-spec.js +336 -0
  23. package/dist/_schemas/program-manifest.d.ts +10 -0
  24. package/dist/_schemas/program-manifest.js +41 -0
  25. package/dist/cli.d.ts +2 -0
  26. package/dist/cli.js +54 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +9 -0
  29. package/dist/init.d.ts +6 -0
  30. package/dist/init.js +81 -0
  31. package/dist/typegen.d.ts +14 -0
  32. package/dist/typegen.js +206 -0
  33. package/dist/validate.d.ts +6 -0
  34. package/dist/validate.js +130 -0
  35. package/docs/development.md +59 -0
  36. package/index.ts +9 -0
  37. package/package.json +47 -0
  38. package/skills/create-loop/SKILL.md +87 -0
  39. package/templates/CLAUDE.md +23 -0
  40. package/templates/README.md +12 -0
  41. package/templates/echo/config.json +23 -0
  42. package/templates/echo/main.ts +4 -0
  43. package/templates/mawaru-runner.yml +71 -0
@@ -0,0 +1,657 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { GraphPort, JsonSchema } from "./node.js"
3
+ import {
4
+ ANY_SCHEMA,
5
+ dataFormatOf,
6
+ dataSchemaOf,
7
+ envelopeDataSchemaOf,
8
+ envelopeSchemaOf,
9
+ fileSchema,
10
+ isAiEnvelopeSchema,
11
+ isFileSchema,
12
+ isSchemaSubset,
13
+ kindPortsViolation,
14
+ propagateDerivedPorts,
15
+ resolvePortSchemas,
16
+ } from "./port-spec.js"
17
+
18
+ let seq = 0
19
+ const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
20
+
21
+ const port = (
22
+ io: "in" | "out",
23
+ key: string,
24
+ schema?: JsonSchema,
25
+ ): GraphPort => ({
26
+ id: uuid(),
27
+ io,
28
+ key,
29
+ name: key,
30
+ schema: schema ?? ANY_SCHEMA,
31
+ })
32
+
33
+ describe("データフォーマットカタログ(S)", () => {
34
+ it("テキスト: S = { type: string } と往復できる", () => {
35
+ const schema = dataSchemaOf({ format: "text" })
36
+ expect(schema).toEqual({ type: "string" })
37
+ expect(dataFormatOf(schema)).toEqual({ format: "text" })
38
+ })
39
+
40
+ it("リスト: 列定義(key・表示名・型)が items に落ち、往復できる", () => {
41
+ const format = {
42
+ format: "list" as const,
43
+ columns: [
44
+ { key: "to", name: "宛先", type: "string" as const },
45
+ { key: "amount", name: "金額", type: "number" as const },
46
+ { key: "paid", name: "支払済", type: "boolean" as const },
47
+ { key: "due", name: "期日", type: "date" as const },
48
+ ],
49
+ }
50
+ const schema = dataSchemaOf(format)
51
+ expect(schema).toEqual({
52
+ type: "array",
53
+ items: {
54
+ type: "object",
55
+ properties: {
56
+ to: { type: "string", title: "宛先" },
57
+ amount: { type: "number", title: "金額" },
58
+ paid: { type: "boolean", title: "支払済" },
59
+ due: { type: "string", format: "date", title: "期日" },
60
+ },
61
+ required: ["to", "amount", "paid", "due"],
62
+ },
63
+ })
64
+ expect(dataFormatOf(schema)).toEqual(format)
65
+ })
66
+
67
+ it("カタログ外のスキーマは逆引きできない(null)", () => {
68
+ expect(dataFormatOf({ type: "object" })).toBeNull()
69
+ expect(
70
+ dataFormatOf({ type: "array", items: { type: "string" } }),
71
+ ).toBeNull()
72
+ expect(dataFormatOf({})).toBeNull()
73
+ })
74
+ })
75
+
76
+ describe("封筒型 { data: T, description: string }", () => {
77
+ it("envelopeSchemaOf:任意の data スキーマを包み、data / description とも必須", () => {
78
+ const text = envelopeSchemaOf({ type: "string" })
79
+ expect(text).toEqual({
80
+ type: "object",
81
+ properties: {
82
+ data: { type: "string" },
83
+ description: { type: "string" },
84
+ },
85
+ required: ["data", "description"],
86
+ })
87
+ // カタログ外の任意型も包める
88
+ const custom = {
89
+ type: "object",
90
+ properties: { title: { type: "string" }, count: { type: "number" } },
91
+ required: ["title"],
92
+ }
93
+ expect(envelopeSchemaOf(custom).properties).toMatchObject({ data: custom })
94
+ })
95
+
96
+ it("envelopeDataSchemaOf:封筒型なら data のサブスキーマ、そうでなければ null", () => {
97
+ const dataSchema = { type: "array", items: { type: "string" } }
98
+ expect(envelopeDataSchemaOf(envelopeSchemaOf(dataSchema))).toEqual(
99
+ dataSchema,
100
+ )
101
+ // data キーが無い / optional / object でない
102
+ expect(envelopeDataSchemaOf({ type: "object", properties: {} })).toBeNull()
103
+ expect(
104
+ envelopeDataSchemaOf({
105
+ type: "object",
106
+ properties: { data: { type: "string" } },
107
+ }),
108
+ ).toBeNull()
109
+ expect(envelopeDataSchemaOf({ type: "string" })).toBeNull()
110
+ expect(envelopeDataSchemaOf(ANY_SCHEMA)).toBeNull()
111
+ })
112
+
113
+ it("isAiEnvelopeSchema:data と description: string の両方が必須のときだけ true", () => {
114
+ expect(isAiEnvelopeSchema(envelopeSchemaOf({ type: "string" }))).toBe(true)
115
+ expect(isAiEnvelopeSchema(envelopeSchemaOf({ type: "object" }))).toBe(true)
116
+ // description が無い / optional / string でない
117
+ expect(
118
+ isAiEnvelopeSchema({
119
+ type: "object",
120
+ properties: { data: { type: "string" } },
121
+ required: ["data"],
122
+ }),
123
+ ).toBe(false)
124
+ expect(
125
+ isAiEnvelopeSchema({
126
+ type: "object",
127
+ properties: {
128
+ data: { type: "string" },
129
+ description: { type: "string" },
130
+ },
131
+ required: ["data"],
132
+ }),
133
+ ).toBe(false)
134
+ expect(
135
+ isAiEnvelopeSchema({
136
+ type: "object",
137
+ properties: {
138
+ data: { type: "string" },
139
+ description: { type: "number" },
140
+ },
141
+ required: ["data", "description"],
142
+ }),
143
+ ).toBe(false)
144
+ expect(isAiEnvelopeSchema(ANY_SCHEMA)).toBe(false)
145
+ })
146
+ })
147
+
148
+ describe("kindPortsViolation(kind 別ポート構成)", () => {
149
+ it("ai は out の key 自由・複数可。in の key だけ in 固定(AI の複数出口)", () => {
150
+ expect(
151
+ kindPortsViolation("ai", [port("in", "in"), port("out", "main")]),
152
+ ).toBeNull()
153
+ expect(
154
+ kindPortsViolation("ai", [port("in", "in"), port("out", "draft")]),
155
+ ).toBeNull()
156
+ expect(
157
+ kindPortsViolation("ai", [
158
+ port("in", "in"),
159
+ port("out", "spam"),
160
+ port("out", "sales"),
161
+ port("out", "support"),
162
+ ]),
163
+ ).toBeNull()
164
+ expect(
165
+ kindPortsViolation("ai", [port("in", "input"), port("out", "main")]),
166
+ ).toMatch(/ai/)
167
+ })
168
+
169
+ it("human は in / approve / reject 固定", () => {
170
+ expect(
171
+ kindPortsViolation("human", [
172
+ port("in", "in"),
173
+ port("out", "approve"),
174
+ port("out", "reject"),
175
+ ]),
176
+ ).toBeNull()
177
+ // 旧 ok/ng は違反
178
+ expect(
179
+ kindPortsViolation("human", [
180
+ port("in", "in"),
181
+ port("out", "ok"),
182
+ port("out", "ng"),
183
+ ]),
184
+ ).toMatch(/human/)
185
+ })
186
+
187
+ it("wait は in / main 固定", () => {
188
+ expect(
189
+ kindPortsViolation("wait", [port("in", "in"), port("out", "main")]),
190
+ ).toBeNull()
191
+ expect(
192
+ kindPortsViolation("wait", [port("in", "in"), port("out", "done")]),
193
+ ).toMatch(/wait/)
194
+ })
195
+
196
+ it("program は out の key 自由・複数可。in の key だけ in 固定", () => {
197
+ expect(
198
+ kindPortsViolation("program", [
199
+ port("in", "in"),
200
+ port("out", "success"),
201
+ port("out", "failure"),
202
+ ]),
203
+ ).toBeNull()
204
+ expect(
205
+ kindPortsViolation("program", [port("in", "input"), port("out", "main")]),
206
+ ).toMatch(/program/)
207
+ })
208
+ })
209
+
210
+ describe("型伝播(導出ポートの解決)", () => {
211
+ const envelope = envelopeSchemaOf({ type: "string" })
212
+
213
+ it("ai → human 直結:approve は封筒を剥がした data 型、reject は content(any)", () => {
214
+ const aiOut = port("out", "main", envelope)
215
+ const ai = { id: uuid(), kind: "ai", ports: [port("in", "in"), aiOut] }
216
+ const humanIn = port("in", "in")
217
+ const approve = port("out", "approve")
218
+ const reject = port("out", "reject")
219
+ const human = {
220
+ id: uuid(),
221
+ kind: "human",
222
+ ports: [humanIn, approve, reject],
223
+ }
224
+ const resolved = resolvePortSchemas(
225
+ [ai, human],
226
+ [{ from_port_id: aiOut.id, to_port_id: humanIn.id }],
227
+ )
228
+ expect(resolved.get(humanIn.id)).toEqual(envelope)
229
+ expect(resolved.get(approve.id)).toEqual({ type: "string" })
230
+ expect(resolved.get(reject.id)).toEqual(ANY_SCHEMA)
231
+ })
232
+
233
+ it("ai → wait → human:素通しで封筒型が伝播し、reject は content(any)のまま", () => {
234
+ // ai の in は宣言型(nodes/ai/<dir>/config.json の inputs.in 由来。実行repoの構造見直し)
235
+ const aiInSchema = {
236
+ type: "object",
237
+ properties: { inquiry: { type: "string" } },
238
+ required: ["inquiry"],
239
+ }
240
+ const aiIn = port("in", "in", aiInSchema)
241
+ const aiOut = port("out", "main", envelope)
242
+ const waitIn = port("in", "in")
243
+ const waitOut = port("out", "main")
244
+ const humanIn = port("in", "in")
245
+ const approve = port("out", "approve")
246
+ const reject = port("out", "reject")
247
+ const ai = { id: uuid(), kind: "ai", ports: [aiIn, aiOut] }
248
+ const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
249
+ const human = {
250
+ id: uuid(),
251
+ kind: "human",
252
+ ports: [humanIn, approve, reject],
253
+ }
254
+ const conns = [
255
+ { from_port_id: aiOut.id, to_port_id: waitIn.id },
256
+ { from_port_id: waitOut.id, to_port_id: humanIn.id },
257
+ ]
258
+ const resolved = resolvePortSchemas([ai, wait, human], conns)
259
+ expect(resolved.get(waitIn.id)).toEqual(envelope)
260
+ expect(resolved.get(waitOut.id)).toEqual(envelope)
261
+ expect(resolved.get(humanIn.id)).toEqual(envelope)
262
+ // 封筒剥がしは「in の直接の接続元が AI ノード」のときだけ。wait 越しは素通し
263
+ expect(resolved.get(approve.id)).toEqual(envelope)
264
+ expect(resolved.get(reject.id)).toEqual(ANY_SCHEMA)
265
+ // ai の in は宣言型がそのまま残る(const 扱いは廃止)
266
+ expect(resolved.get(aiIn.id)).toEqual(aiInSchema)
267
+ })
268
+
269
+ it("未接続の導出ポート・純パラメトリック閉路は any", () => {
270
+ const in1 = port("in", "in")
271
+ const out1 = port("out", "main")
272
+ const in2 = port("in", "in")
273
+ const out2 = port("out", "main")
274
+ const w1 = { id: uuid(), kind: "wait", ports: [in1, out1] }
275
+ const w2 = { id: uuid(), kind: "wait", ports: [in2, out2] }
276
+ // w1 → w2 → w1 の閉路(宣言の源泉なし)
277
+ const resolved = resolvePortSchemas(
278
+ [w1, w2],
279
+ [
280
+ { from_port_id: out1.id, to_port_id: in2.id },
281
+ { from_port_id: out2.id, to_port_id: in1.id },
282
+ ],
283
+ )
284
+ expect(resolved.get(out1.id)).toEqual(ANY_SCHEMA)
285
+ expect(resolved.get(out2.id)).toEqual(ANY_SCHEMA)
286
+ })
287
+
288
+ it("program の in は接続鏡映(sticky):具体型の接続元だけ上書きする", () => {
289
+ const declared = {
290
+ type: "object",
291
+ properties: { inquiry: { type: "string" } },
292
+ required: ["inquiry"],
293
+ }
294
+ const aiOut = port("out", "main", envelope)
295
+ const ai = { id: uuid(), kind: "ai", ports: [port("in", "in"), aiOut] }
296
+ const progIn = port("in", "in", declared)
297
+ const program = {
298
+ id: uuid(),
299
+ kind: "program",
300
+ ports: [progIn, port("out", "main", { type: "object" })],
301
+ }
302
+ const resolved = resolvePortSchemas(
303
+ [ai, program],
304
+ [{ from_port_id: aiOut.id, to_port_id: progIn.id }],
305
+ )
306
+ expect(resolved.get(progIn.id)).toEqual(envelope)
307
+ })
308
+
309
+ it("ai の in も接続鏡映(sticky):具体型の接続元だけ上書きする", () => {
310
+ const declared = {
311
+ type: "object",
312
+ properties: { topic: { type: "string" } },
313
+ }
314
+ const progOut = port("out", "main", envelope)
315
+ const program = {
316
+ id: uuid(),
317
+ kind: "program",
318
+ ports: [port("in", "in"), progOut],
319
+ }
320
+ const aiIn = port("in", "in", declared)
321
+ const ai = { id: uuid(), kind: "ai", ports: [aiIn, port("out", "main")] }
322
+ // 具体型の接続元 → 上書き(接続両端の同値同期)
323
+ const resolved = resolvePortSchemas(
324
+ [program, ai],
325
+ [{ from_port_id: progOut.id, to_port_id: aiIn.id }],
326
+ )
327
+ expect(resolved.get(aiIn.id)).toEqual(envelope)
328
+ // 接続元が any(源泉の無い wait)→ 宣言を維持
329
+ const waitOut = port("out", "main")
330
+ const wait = {
331
+ id: uuid(),
332
+ kind: "wait",
333
+ ports: [port("in", "in"), waitOut],
334
+ }
335
+ const aiIn2 = port("in", "in", declared)
336
+ const ai2 = { id: uuid(), kind: "ai", ports: [aiIn2, port("out", "main")] }
337
+ const kept = resolvePortSchemas(
338
+ [wait, ai2],
339
+ [{ from_port_id: waitOut.id, to_port_id: aiIn2.id }],
340
+ )
341
+ expect(kept.get(aiIn2.id)).toEqual(declared)
342
+ })
343
+
344
+ it("program の in:未接続・接続元 any は宣言スキーマを維持する", () => {
345
+ const declared = { type: "object" }
346
+ // 未接続
347
+ const alone = port("in", "in", declared)
348
+ const program1 = {
349
+ id: uuid(),
350
+ kind: "program",
351
+ ports: [alone, port("out", "main")],
352
+ }
353
+ expect(resolvePortSchemas([program1], []).get(alone.id)).toEqual(declared)
354
+ // 接続元が any(源泉の無い wait)
355
+ const waitOut = port("out", "main")
356
+ const wait = {
357
+ id: uuid(),
358
+ kind: "wait",
359
+ ports: [port("in", "in"), waitOut],
360
+ }
361
+ const connected = port("in", "in", declared)
362
+ const program2 = {
363
+ id: uuid(),
364
+ kind: "program",
365
+ ports: [connected, port("out", "main")],
366
+ }
367
+ const resolved = resolvePortSchemas(
368
+ [wait, program2],
369
+ [{ from_port_id: waitOut.id, to_port_id: connected.id }],
370
+ )
371
+ expect(resolved.get(connected.id)).toEqual(declared)
372
+ })
373
+
374
+ it("program の in:wait 越しでも具体型が鏡映される(固定点反復)", () => {
375
+ const aiOut = port("out", "main", envelope)
376
+ const ai = { id: uuid(), kind: "ai", ports: [port("in", "in"), aiOut] }
377
+ const waitIn = port("in", "in")
378
+ const waitOut = port("out", "main")
379
+ const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
380
+ const progIn = port("in", "in", { type: "object" })
381
+ const program = {
382
+ id: uuid(),
383
+ kind: "program",
384
+ ports: [progIn, port("out", "main")],
385
+ }
386
+ const resolved = resolvePortSchemas(
387
+ [program, wait, ai], // program を先頭に置いても反復で解決される
388
+ [
389
+ { from_port_id: aiOut.id, to_port_id: waitIn.id },
390
+ { from_port_id: waitOut.id, to_port_id: progIn.id },
391
+ ],
392
+ )
393
+ expect(resolved.get(progIn.id)).toEqual(envelope)
394
+ })
395
+
396
+ // start の run 入力契約は接続先から導出する
397
+ // (開始ノードの入力契約を接続先から導出.md)
398
+ const startNode = () => {
399
+ const inPort = port("in", "in")
400
+ const outMain = port("out", "main")
401
+ return {
402
+ node: { id: uuid(), kind: "start", ports: [inPort, outMain] },
403
+ inPort,
404
+ outMain,
405
+ }
406
+ }
407
+ const programNode = (declared: JsonSchema) => {
408
+ const inPort = port("in", "in", declared)
409
+ return {
410
+ node: {
411
+ id: uuid(),
412
+ kind: "program",
413
+ ports: [inPort, port("out", "main")],
414
+ },
415
+ inPort,
416
+ }
417
+ }
418
+
419
+ it("start:接続先(program の宣言 in)が out main と in に転写される", () => {
420
+ const declared = {
421
+ type: "object",
422
+ properties: { message: { type: "string" } },
423
+ required: ["message"],
424
+ }
425
+ const start = startNode()
426
+ const program = programNode(declared)
427
+ const resolved = resolvePortSchemas(
428
+ [start.node, program.node],
429
+ [{ from_port_id: start.outMain.id, to_port_id: program.inPort.id }],
430
+ )
431
+ expect(resolved.get(start.outMain.id)).toEqual(declared)
432
+ expect(resolved.get(start.inPort.id)).toEqual(declared)
433
+ })
434
+
435
+ it("start:未接続は any(ports.schema に古い値が残っていても引きずらない)", () => {
436
+ const inPort = port("in", "in", { type: "object" })
437
+ const outMain = port("out", "main", { type: "object" })
438
+ const start = { id: uuid(), kind: "start", ports: [inPort, outMain] }
439
+ const resolved = resolvePortSchemas([start], [])
440
+ expect(resolved.get(outMain.id)).toEqual(ANY_SCHEMA)
441
+ expect(resolved.get(inPort.id)).toEqual(ANY_SCHEMA)
442
+ })
443
+
444
+ it("start:fan-out で非 any が1種類(program+導出 wait)ならそれを採用し、wait へも流れる", () => {
445
+ const declared = {
446
+ type: "object",
447
+ properties: { message: { type: "string" } },
448
+ required: ["message"],
449
+ }
450
+ const start = startNode()
451
+ const program = programNode(declared)
452
+ const waitIn = port("in", "in")
453
+ const waitOut = port("out", "main")
454
+ const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
455
+ const resolved = resolvePortSchemas(
456
+ [start.node, program.node, wait],
457
+ [
458
+ { from_port_id: start.outMain.id, to_port_id: program.inPort.id },
459
+ { from_port_id: start.outMain.id, to_port_id: waitIn.id },
460
+ ],
461
+ )
462
+ expect(resolved.get(start.inPort.id)).toEqual(declared)
463
+ expect(resolved.get(waitIn.id)).toEqual(declared)
464
+ expect(resolved.get(waitOut.id)).toEqual(declared)
465
+ })
466
+
467
+ it("start:fan-out で型が割れたら any", () => {
468
+ const start = startNode()
469
+ const a = programNode({
470
+ type: "object",
471
+ properties: { a: { type: "string" } },
472
+ required: ["a"],
473
+ })
474
+ const b = programNode({
475
+ type: "object",
476
+ properties: { b: { type: "number" } },
477
+ required: ["b"],
478
+ })
479
+ const resolved = resolvePortSchemas(
480
+ [start.node, a.node, b.node],
481
+ [
482
+ { from_port_id: start.outMain.id, to_port_id: a.inPort.id },
483
+ { from_port_id: start.outMain.id, to_port_id: b.inPort.id },
484
+ ],
485
+ )
486
+ expect(resolved.get(start.outMain.id)).toEqual(ANY_SCHEMA)
487
+ expect(resolved.get(start.inPort.id)).toEqual(ANY_SCHEMA)
488
+ })
489
+
490
+ it("start:宣言を持たない経路(start → wait → …)は any(パラメトリック閉路の割り切り)", () => {
491
+ const start = startNode()
492
+ const waitIn = port("in", "in")
493
+ const waitOut = port("out", "main")
494
+ const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
495
+ const resolved = resolvePortSchemas(
496
+ [start.node, wait],
497
+ [{ from_port_id: start.outMain.id, to_port_id: waitIn.id }],
498
+ )
499
+ expect(resolved.get(start.inPort.id)).toEqual(ANY_SCHEMA)
500
+ expect(resolved.get(start.outMain.id)).toEqual(ANY_SCHEMA)
501
+ })
502
+
503
+ it("propagateDerivedPorts:schema を書き戻し、変化のないノードは同じ参照を返す", () => {
504
+ const progOut = port("out", "main", envelope)
505
+ const program = {
506
+ id: uuid(),
507
+ kind: "program",
508
+ ports: [port("in", "in", { type: "object" }), progOut],
509
+ }
510
+ const waitIn = port("in", "in")
511
+ const wait = {
512
+ id: uuid(),
513
+ kind: "wait",
514
+ ports: [waitIn, port("out", "main")],
515
+ }
516
+ const next = propagateDerivedPorts(
517
+ [program, wait],
518
+ [{ from_port_id: progOut.id, to_port_id: waitIn.id }],
519
+ )
520
+ expect(next[0]).toBe(program) // 宣言のみ=無変更
521
+ expect(next[1]?.ports.map((p) => p.schema)).toEqual([envelope, envelope])
522
+ // 接続を消すと any に戻る
523
+ const cleared = propagateDerivedPorts(next, [])
524
+ expect(cleared[1]?.ports.map((p) => p.schema)).toEqual([
525
+ ANY_SCHEMA,
526
+ ANY_SCHEMA,
527
+ ])
528
+ })
529
+
530
+ it("propagateDerivedPorts:side / offset(表示位置)を落とさない", () => {
531
+ const progOut = {
532
+ ...port("out", "main", envelope),
533
+ side: "top" as const,
534
+ offset: 0.3,
535
+ }
536
+ const program = {
537
+ id: uuid(),
538
+ kind: "program",
539
+ ports: [port("in", "in", { type: "object" }), progOut],
540
+ }
541
+ const waitIn = port("in", "in")
542
+ const waitOut = { ...port("out", "main"), side: "left" as const, offset: 1 }
543
+ const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
544
+ const next = propagateDerivedPorts(
545
+ [program, wait],
546
+ [{ from_port_id: progOut.id, to_port_id: waitIn.id }],
547
+ )
548
+ // 書き戻しが起きた wait 側でも side / offset が保持される
549
+ const nextWaitOut = next[1]?.ports.find((p) => p.io === "out")
550
+ expect(nextWaitOut).toMatchObject({ side: "left", offset: 1 })
551
+ expect(next[0]?.ports[1]).toMatchObject({ side: "top", offset: 0.3 })
552
+ })
553
+ })
554
+
555
+ describe("file 型(nominal)", () => {
556
+ it("fileSchema はマーカー付き object、isFileSchema で識別できる", () => {
557
+ const schema = fileSchema()
558
+ expect(schema["x-mawaru-type"]).toBe("file")
559
+ expect(schema.required).toEqual(["path", "name"])
560
+ expect(isFileSchema(schema)).toBe(true)
561
+ expect(isFileSchema({ type: "object" })).toBe(false)
562
+ // 構造が file と同じでもマーカーが無ければ file ではない
563
+ expect(
564
+ isFileSchema({
565
+ type: "object",
566
+ properties: { path: { type: "string" }, name: { type: "string" } },
567
+ required: ["path", "name"],
568
+ }),
569
+ ).toBe(false)
570
+ })
571
+
572
+ it("接続は file 同士だけ可・file → any は可・file と非 file object は不可", () => {
573
+ const file = fileSchema()
574
+ const lookalike = {
575
+ type: "object",
576
+ properties: { path: { type: "string" }, name: { type: "string" } },
577
+ required: ["path", "name"],
578
+ }
579
+ // file ↔ file
580
+ expect(isSchemaSubset(file, fileSchema())).toBe(true)
581
+ // file → any / any → file
582
+ expect(isSchemaSubset(file, ANY_SCHEMA)).toBe(true)
583
+ expect(isSchemaSubset(ANY_SCHEMA, file)).toBe(true)
584
+ // 構造が同じだけの object は file に繋がない(nominal)
585
+ expect(isSchemaSubset(lookalike, file)).toBe(false)
586
+ expect(isSchemaSubset(file, lookalike)).toBe(false)
587
+ // file[] 同士は items が file 同士なら可
588
+ expect(
589
+ isSchemaSubset(
590
+ { type: "array", items: fileSchema() },
591
+ { type: "array", items: fileSchema() },
592
+ ),
593
+ ).toBe(true)
594
+ })
595
+ })
596
+
597
+ describe("isSchemaSubset(伝播型 ⊆ 宣言スキーマ)", () => {
598
+ it("any はどちら向きも許可", () => {
599
+ expect(isSchemaSubset(ANY_SCHEMA, { type: "object" })).toBe(true)
600
+ expect(isSchemaSubset({ type: "object" }, ANY_SCHEMA)).toBe(true)
601
+ })
602
+
603
+ it("object:宣言側の required が伝播側に必須として存在し、型互換であること", () => {
604
+ const sup = {
605
+ type: "object",
606
+ properties: { data: { type: "string" } },
607
+ required: ["data"],
608
+ }
609
+ expect(
610
+ isSchemaSubset(
611
+ {
612
+ type: "object",
613
+ properties: {
614
+ data: { type: "string" },
615
+ description: { type: "string" },
616
+ },
617
+ required: ["data", "description"],
618
+ },
619
+ sup,
620
+ ),
621
+ ).toBe(true)
622
+ // data が無い / optional / 型不一致
623
+ expect(isSchemaSubset({ type: "object", properties: {} }, sup)).toBe(false)
624
+ expect(
625
+ isSchemaSubset(
626
+ { type: "object", properties: { data: { type: "string" } } },
627
+ sup,
628
+ ),
629
+ ).toBe(false)
630
+ expect(
631
+ isSchemaSubset(
632
+ {
633
+ type: "object",
634
+ properties: { data: { type: "number" } },
635
+ required: ["data"],
636
+ },
637
+ sup,
638
+ ),
639
+ ).toBe(false)
640
+ })
641
+
642
+ it("type 不一致・array の items 互換", () => {
643
+ expect(isSchemaSubset({ type: "string" }, { type: "object" })).toBe(false)
644
+ expect(
645
+ isSchemaSubset(
646
+ { type: "array", items: { type: "string" } },
647
+ { type: "array", items: { type: "string" } },
648
+ ),
649
+ ).toBe(true)
650
+ expect(
651
+ isSchemaSubset(
652
+ { type: "array", items: { type: "number" } },
653
+ { type: "array", items: { type: "string" } },
654
+ ),
655
+ ).toBe(false)
656
+ })
657
+ })