@mawaru/sdk 0.5.0 → 0.9.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.
@@ -1,18 +1,22 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { GraphPort, JsonSchema } from "./node.js"
3
3
  import {
4
- ANY_SCHEMA,
5
4
  dataFormatOf,
6
5
  dataSchemaOf,
7
6
  envelopeDataSchemaOf,
8
7
  envelopeSchemaOf,
9
8
  fileSchema,
9
+ hasInboundConflict,
10
+ instructionSchema,
10
11
  isAiEnvelopeSchema,
11
12
  isFileSchema,
12
13
  isSchemaSubset,
13
14
  kindPortsViolation,
15
+ mergeInboundSchemas,
14
16
  propagateDerivedPorts,
15
17
  resolvePortSchemas,
18
+ schemaFit,
19
+ UNDECLARED_SCHEMA,
16
20
  } from "./port-spec.js"
17
21
 
18
22
  let seq = 0
@@ -27,7 +31,7 @@ const port = (
27
31
  io,
28
32
  key,
29
33
  name: key,
30
- schema: schema ?? ANY_SCHEMA,
34
+ schema: schema ?? UNDECLARED_SCHEMA,
31
35
  })
32
36
 
33
37
  describe("データフォーマットカタログ(S)", () => {
@@ -107,7 +111,7 @@ describe("封筒型 { data: T, description: string }", () => {
107
111
  }),
108
112
  ).toBeNull()
109
113
  expect(envelopeDataSchemaOf({ type: "string" })).toBeNull()
110
- expect(envelopeDataSchemaOf(ANY_SCHEMA)).toBeNull()
114
+ expect(envelopeDataSchemaOf(UNDECLARED_SCHEMA)).toBeNull()
111
115
  })
112
116
 
113
117
  it("isAiEnvelopeSchema:data と description: string の両方が必須のときだけ true", () => {
@@ -141,28 +145,33 @@ describe("封筒型 { data: T, description: string }", () => {
141
145
  required: ["data", "description"],
142
146
  }),
143
147
  ).toBe(false)
144
- expect(isAiEnvelopeSchema(ANY_SCHEMA)).toBe(false)
148
+ expect(isAiEnvelopeSchema(UNDECLARED_SCHEMA)).toBe(false)
145
149
  })
146
150
  })
147
151
 
148
152
  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()
153
+ it("ai は out の key 自由・複数可。in in / instruction の2口固定(AI の複数出口)", () => {
154
+ const ins = [port("in", "in"), port("in", "instruction")]
155
+ expect(kindPortsViolation("ai", [...ins, port("out", "main")])).toBeNull()
156
+ expect(kindPortsViolation("ai", [...ins, port("out", "draft")])).toBeNull()
156
157
  expect(
157
158
  kindPortsViolation("ai", [
158
- port("in", "in"),
159
+ ...ins,
159
160
  port("out", "spam"),
160
161
  port("out", "sales"),
161
162
  port("out", "support"),
162
163
  ]),
163
164
  ).toBeNull()
165
+ // instruction が無い・key が違うのは違反
166
+ expect(
167
+ kindPortsViolation("ai", [port("in", "in"), port("out", "main")]),
168
+ ).toMatch(/instruction/)
164
169
  expect(
165
- kindPortsViolation("ai", [port("in", "input"), port("out", "main")]),
170
+ kindPortsViolation("ai", [
171
+ port("in", "input"),
172
+ port("in", "instruction"),
173
+ port("out", "main"),
174
+ ]),
166
175
  ).toMatch(/ai/)
167
176
  })
168
177
 
@@ -210,9 +219,13 @@ describe("kindPortsViolation(kind 別ポート構成)", () => {
210
219
  describe("型伝播(導出ポートの解決)", () => {
211
220
  const envelope = envelopeSchemaOf({ type: "string" })
212
221
 
213
- it("ai → human 直結:approve は封筒を剥がした data 型、reject は content(any)", () => {
222
+ it("ai → human 直結:approve は封筒を剥がした data 型、reject は指示エンベロープ", () => {
214
223
  const aiOut = port("out", "main", envelope)
215
- const ai = { id: uuid(), kind: "ai", ports: [port("in", "in"), aiOut] }
224
+ const ai = {
225
+ id: uuid(),
226
+ kind: "ai",
227
+ ports: [port("in", "in"), port("in", "instruction"), aiOut],
228
+ }
216
229
  const humanIn = port("in", "in")
217
230
  const approve = port("out", "approve")
218
231
  const reject = port("out", "reject")
@@ -227,10 +240,10 @@ describe("型伝播(導出ポートの解決)", () => {
227
240
  )
228
241
  expect(resolved.get(humanIn.id)).toEqual(envelope)
229
242
  expect(resolved.get(approve.id)).toEqual({ type: "string" })
230
- expect(resolved.get(reject.id)).toEqual(ANY_SCHEMA)
243
+ expect(resolved.get(reject.id)).toEqual(instructionSchema())
231
244
  })
232
245
 
233
- it("ai → wait → human:素通しで封筒型が伝播し、reject は content(any)のまま", () => {
246
+ it("ai → wait → human:素通しで封筒型が伝播し、reject は指示エンベロープのまま", () => {
234
247
  // ai の in は宣言型(nodes/ai/<dir>/config.json の inputs.in 由来。実行repoの構造見直し)
235
248
  const aiInSchema = {
236
249
  type: "object",
@@ -244,7 +257,11 @@ describe("型伝播(導出ポートの解決)", () => {
244
257
  const humanIn = port("in", "in")
245
258
  const approve = port("out", "approve")
246
259
  const reject = port("out", "reject")
247
- const ai = { id: uuid(), kind: "ai", ports: [aiIn, aiOut] }
260
+ const ai = {
261
+ id: uuid(),
262
+ kind: "ai",
263
+ ports: [aiIn, port("in", "instruction"), aiOut],
264
+ }
248
265
  const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
249
266
  const human = {
250
267
  id: uuid(),
@@ -261,7 +278,7 @@ describe("型伝播(導出ポートの解決)", () => {
261
278
  expect(resolved.get(humanIn.id)).toEqual(envelope)
262
279
  // 封筒剥がしは「in の直接の接続元が AI ノード」のときだけ。wait 越しは素通し
263
280
  expect(resolved.get(approve.id)).toEqual(envelope)
264
- expect(resolved.get(reject.id)).toEqual(ANY_SCHEMA)
281
+ expect(resolved.get(reject.id)).toEqual(instructionSchema())
265
282
  // ai の in は宣言型がそのまま残る(const 扱いは廃止)
266
283
  expect(resolved.get(aiIn.id)).toEqual(aiInSchema)
267
284
  })
@@ -281,8 +298,8 @@ describe("型伝播(導出ポートの解決)", () => {
281
298
  { from_port_id: out2.id, to_port_id: in1.id },
282
299
  ],
283
300
  )
284
- expect(resolved.get(out1.id)).toEqual(ANY_SCHEMA)
285
- expect(resolved.get(out2.id)).toEqual(ANY_SCHEMA)
301
+ expect(resolved.get(out1.id)).toEqual(UNDECLARED_SCHEMA)
302
+ expect(resolved.get(out2.id)).toEqual(UNDECLARED_SCHEMA)
286
303
  })
287
304
 
288
305
  it("program の in は接続鏡映(sticky):具体型の接続元だけ上書きする", () => {
@@ -292,7 +309,11 @@ describe("型伝播(導出ポートの解決)", () => {
292
309
  required: ["inquiry"],
293
310
  }
294
311
  const aiOut = port("out", "main", envelope)
295
- const ai = { id: uuid(), kind: "ai", ports: [port("in", "in"), aiOut] }
312
+ const ai = {
313
+ id: uuid(),
314
+ kind: "ai",
315
+ ports: [port("in", "in"), port("in", "instruction"), aiOut],
316
+ }
296
317
  const progIn = port("in", "in", declared)
297
318
  const program = {
298
319
  id: uuid(),
@@ -318,7 +339,11 @@ describe("型伝播(導出ポートの解決)", () => {
318
339
  ports: [port("in", "in"), progOut],
319
340
  }
320
341
  const aiIn = port("in", "in", declared)
321
- const ai = { id: uuid(), kind: "ai", ports: [aiIn, port("out", "main")] }
342
+ const ai = {
343
+ id: uuid(),
344
+ kind: "ai",
345
+ ports: [aiIn, port("in", "instruction"), port("out", "main")],
346
+ }
322
347
  // 具体型の接続元 → 上書き(接続両端の同値同期)
323
348
  const resolved = resolvePortSchemas(
324
349
  [program, ai],
@@ -333,7 +358,11 @@ describe("型伝播(導出ポートの解決)", () => {
333
358
  ports: [port("in", "in"), waitOut],
334
359
  }
335
360
  const aiIn2 = port("in", "in", declared)
336
- const ai2 = { id: uuid(), kind: "ai", ports: [aiIn2, port("out", "main")] }
361
+ const ai2 = {
362
+ id: uuid(),
363
+ kind: "ai",
364
+ ports: [aiIn2, port("in", "instruction"), port("out", "main")],
365
+ }
337
366
  const kept = resolvePortSchemas(
338
367
  [wait, ai2],
339
368
  [{ from_port_id: waitOut.id, to_port_id: aiIn2.id }],
@@ -373,7 +402,11 @@ describe("型伝播(導出ポートの解決)", () => {
373
402
 
374
403
  it("program の in:wait 越しでも具体型が鏡映される(固定点反復)", () => {
375
404
  const aiOut = port("out", "main", envelope)
376
- const ai = { id: uuid(), kind: "ai", ports: [port("in", "in"), aiOut] }
405
+ const ai = {
406
+ id: uuid(),
407
+ kind: "ai",
408
+ ports: [port("in", "in"), port("in", "instruction"), aiOut],
409
+ }
377
410
  const waitIn = port("in", "in")
378
411
  const waitOut = port("out", "main")
379
412
  const wait = { id: uuid(), kind: "wait", ports: [waitIn, waitOut] }
@@ -437,8 +470,8 @@ describe("型伝播(導出ポートの解決)", () => {
437
470
  const outMain = port("out", "main", { type: "object" })
438
471
  const start = { id: uuid(), kind: "start", ports: [inPort, outMain] }
439
472
  const resolved = resolvePortSchemas([start], [])
440
- expect(resolved.get(outMain.id)).toEqual(ANY_SCHEMA)
441
- expect(resolved.get(inPort.id)).toEqual(ANY_SCHEMA)
473
+ expect(resolved.get(outMain.id)).toEqual(UNDECLARED_SCHEMA)
474
+ expect(resolved.get(inPort.id)).toEqual(UNDECLARED_SCHEMA)
442
475
  })
443
476
 
444
477
  it("start:fan-out で非 any が1種類(program+導出 wait)ならそれを採用し、wait へも流れる", () => {
@@ -483,8 +516,8 @@ describe("型伝播(導出ポートの解決)", () => {
483
516
  { from_port_id: start.outMain.id, to_port_id: b.inPort.id },
484
517
  ],
485
518
  )
486
- expect(resolved.get(start.outMain.id)).toEqual(ANY_SCHEMA)
487
- expect(resolved.get(start.inPort.id)).toEqual(ANY_SCHEMA)
519
+ expect(resolved.get(start.outMain.id)).toEqual(UNDECLARED_SCHEMA)
520
+ expect(resolved.get(start.inPort.id)).toEqual(UNDECLARED_SCHEMA)
488
521
  })
489
522
 
490
523
  it("start:宣言を持たない経路(start → wait → …)は any(パラメトリック閉路の割り切り)", () => {
@@ -496,8 +529,8 @@ describe("型伝播(導出ポートの解決)", () => {
496
529
  [start.node, wait],
497
530
  [{ from_port_id: start.outMain.id, to_port_id: waitIn.id }],
498
531
  )
499
- expect(resolved.get(start.inPort.id)).toEqual(ANY_SCHEMA)
500
- expect(resolved.get(start.outMain.id)).toEqual(ANY_SCHEMA)
532
+ expect(resolved.get(start.inPort.id)).toEqual(UNDECLARED_SCHEMA)
533
+ expect(resolved.get(start.outMain.id)).toEqual(UNDECLARED_SCHEMA)
501
534
  })
502
535
 
503
536
  it("propagateDerivedPorts:schema を書き戻し、変化のないノードは同じ参照を返す", () => {
@@ -522,8 +555,8 @@ describe("型伝播(導出ポートの解決)", () => {
522
555
  // 接続を消すと any に戻る
523
556
  const cleared = propagateDerivedPorts(next, [])
524
557
  expect(cleared[1]?.ports.map((p) => p.schema)).toEqual([
525
- ANY_SCHEMA,
526
- ANY_SCHEMA,
558
+ UNDECLARED_SCHEMA,
559
+ UNDECLARED_SCHEMA,
527
560
  ])
528
561
  })
529
562
 
@@ -552,6 +585,73 @@ describe("型伝播(導出ポートの解決)", () => {
552
585
  })
553
586
  })
554
587
 
588
+ describe("型伝播:fan-in(同じ in ポートへの流入2本以上)", () => {
589
+ const str = { type: "string" } as const
590
+ const num = { type: "number" } as const
591
+
592
+ // program の out は宣言型なので、fan-in の元として具体型を持たせられる
593
+ const source = (schema: Record<string, unknown>) => {
594
+ const out = port("out", "main", schema)
595
+ return {
596
+ node: { id: uuid(), kind: "program", ports: [port("in", "in"), out] },
597
+ out,
598
+ }
599
+ }
600
+ const sink = () => {
601
+ const inPort = port("in", "in")
602
+ const out = port("out", "main")
603
+ return {
604
+ node: { id: uuid(), kind: "wait", ports: [inPort, out] },
605
+ inPort,
606
+ out,
607
+ }
608
+ }
609
+
610
+ it("流入が全部同じ型ならその型が伝播する(従来は any に落ちていた)", () => {
611
+ const a = source(str)
612
+ const b = source(str)
613
+ const w = sink()
614
+ const resolved = resolvePortSchemas(
615
+ [a.node, b.node, w.node],
616
+ [
617
+ { from_port_id: a.out.id, to_port_id: w.inPort.id },
618
+ { from_port_id: b.out.id, to_port_id: w.inPort.id },
619
+ ],
620
+ )
621
+ expect(resolved.get(w.inPort.id)).toEqual(str)
622
+ // 素通しなので下流にも伝わる
623
+ expect(resolved.get(w.out.id)).toEqual(str)
624
+ })
625
+
626
+ it("具体型1種類+未宣言 の混在は、その具体型が採用される", () => {
627
+ const a = source(str)
628
+ const b = sink() // out main は導出=未宣言
629
+ const w = sink()
630
+ const resolved = resolvePortSchemas(
631
+ [a.node, b.node, w.node],
632
+ [
633
+ { from_port_id: a.out.id, to_port_id: w.inPort.id },
634
+ { from_port_id: b.out.id, to_port_id: w.inPort.id },
635
+ ],
636
+ )
637
+ expect(resolved.get(w.inPort.id)).toEqual(str)
638
+ })
639
+
640
+ it("型が割れているときは未宣言に落ちる(保存時に graph が弾く)", () => {
641
+ const a = source(str)
642
+ const b = source(num)
643
+ const w = sink()
644
+ const resolved = resolvePortSchemas(
645
+ [a.node, b.node, w.node],
646
+ [
647
+ { from_port_id: a.out.id, to_port_id: w.inPort.id },
648
+ { from_port_id: b.out.id, to_port_id: w.inPort.id },
649
+ ],
650
+ )
651
+ expect(resolved.get(w.inPort.id)).toEqual(UNDECLARED_SCHEMA)
652
+ })
653
+ })
654
+
555
655
  describe("file 型(nominal)", () => {
556
656
  it("fileSchema はマーカー付き object、isFileSchema で識別できる", () => {
557
657
  const schema = fileSchema()
@@ -579,8 +679,8 @@ describe("file 型(nominal)", () => {
579
679
  // file ↔ file
580
680
  expect(isSchemaSubset(file, fileSchema())).toBe(true)
581
681
  // file → any / any → file
582
- expect(isSchemaSubset(file, ANY_SCHEMA)).toBe(true)
583
- expect(isSchemaSubset(ANY_SCHEMA, file)).toBe(true)
682
+ expect(isSchemaSubset(file, UNDECLARED_SCHEMA)).toBe(true)
683
+ expect(isSchemaSubset(UNDECLARED_SCHEMA, file)).toBe(true)
584
684
  // 構造が同じだけの object は file に繋がない(nominal)
585
685
  expect(isSchemaSubset(lookalike, file)).toBe(false)
586
686
  expect(isSchemaSubset(file, lookalike)).toBe(false)
@@ -596,8 +696,8 @@ describe("file 型(nominal)", () => {
596
696
 
597
697
  describe("isSchemaSubset(伝播型 ⊆ 宣言スキーマ)", () => {
598
698
  it("any はどちら向きも許可", () => {
599
- expect(isSchemaSubset(ANY_SCHEMA, { type: "object" })).toBe(true)
600
- expect(isSchemaSubset({ type: "object" }, ANY_SCHEMA)).toBe(true)
699
+ expect(isSchemaSubset(UNDECLARED_SCHEMA, { type: "object" })).toBe(true)
700
+ expect(isSchemaSubset({ type: "object" }, UNDECLARED_SCHEMA)).toBe(true)
601
701
  })
602
702
 
603
703
  it("object:宣言側の required が伝播側に必須として存在し、型互換であること", () => {
@@ -655,3 +755,56 @@ describe("isSchemaSubset(伝播型 ⊆ 宣言スキーマ)", () => {
655
755
  ).toBe(false)
656
756
  })
657
757
  })
758
+
759
+ describe("schemaFit(3値の型判定)", () => {
760
+ const str = { type: "string" } as const
761
+ const num = { type: "number" } as const
762
+
763
+ it("具体型どうしは ok / mismatch を返す", () => {
764
+ expect(schemaFit(str, str)).toBe("ok")
765
+ expect(schemaFit(str, num)).toBe("mismatch")
766
+ })
767
+
768
+ it("どちらかが未宣言なら unknown(適合とも不適合とも言わない)", () => {
769
+ expect(schemaFit(UNDECLARED_SCHEMA, str)).toBe("unknown")
770
+ expect(schemaFit(str, UNDECLARED_SCHEMA)).toBe("unknown")
771
+ expect(schemaFit(UNDECLARED_SCHEMA, UNDECLARED_SCHEMA)).toBe("unknown")
772
+ })
773
+
774
+ it("isSchemaSubset は unknown を通す(保存を止めない)", () => {
775
+ expect(isSchemaSubset(UNDECLARED_SCHEMA, str)).toBe(true)
776
+ expect(isSchemaSubset(str, num)).toBe(false)
777
+ })
778
+
779
+ it("file は nominal(file 同士だけ ok)", () => {
780
+ expect(schemaFit(fileSchema(), fileSchema())).toBe("ok")
781
+ expect(schemaFit({ type: "object" }, fileSchema())).toBe("mismatch")
782
+ })
783
+ })
784
+
785
+ describe("mergeInboundSchemas / hasInboundConflict(fan-in)", () => {
786
+ const str = { type: "string" } as const
787
+ const num = { type: "number" } as const
788
+
789
+ it("流入が全部同じ具体型ならその型を採用する", () => {
790
+ expect(mergeInboundSchemas([str, str])).toEqual(str)
791
+ expect(hasInboundConflict([str, str])).toBe(false)
792
+ })
793
+
794
+ it("具体型1種類+未宣言 の混在は、その具体型を採用する", () => {
795
+ expect(mergeInboundSchemas([UNDECLARED_SCHEMA, str])).toEqual(str)
796
+ expect(hasInboundConflict([UNDECLARED_SCHEMA, str])).toBe(false)
797
+ })
798
+
799
+ it("具体型が2種類以上なら未宣言に落とし、衝突として検出する", () => {
800
+ expect(mergeInboundSchemas([str, num])).toEqual(UNDECLARED_SCHEMA)
801
+ expect(hasInboundConflict([str, num])).toBe(true)
802
+ })
803
+
804
+ it("すべて未宣言なら未宣言", () => {
805
+ expect(mergeInboundSchemas([UNDECLARED_SCHEMA, UNDECLARED_SCHEMA])).toEqual(
806
+ UNDECLARED_SCHEMA,
807
+ )
808
+ expect(hasInboundConflict([])).toBe(false)
809
+ })
810
+ })