@particle-academy/fancy-conformance 0.23.0 → 0.24.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 CHANGED
@@ -69,6 +69,7 @@ verbatim.
69
69
  | `shared/image-header` | 16 | Image dimensions read from the header bytes, without an image library |
70
70
  | `shared/flow-run-identity` | 25 | fancy-flow's run/step identity: the idempotency key a retrying connector sends, and when a retry may still reuse it |
71
71
  | `flow/graph-runs` | 23 | Whole-graph execution: the same `WorkflowSchema` in, the same `RunResult.outputs` out |
72
+ | `flow/run-diagnostics` | 14 | The run-time warnings for a graph that delivers nothing: an edge naming a port its source can never publish, and a route taken on a path that did not resolve. Half the rows pin when to stay silent |
72
73
 
73
74
  Every case carries an `id`, a `title`, the suite version it arrived in, and —
74
75
  where it exists to catch something specific — a `notes` field saying what.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.23.0
1
+ 0.24.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI — so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,1334 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/run-diagnostics",
4
+ "cases": [
5
+ {
6
+ "id": "0001-branch-routes-on-an-unresolved-path",
7
+ "title": "A branch whose whole-expression condition resolves to nothing warns, naming the path and the port it took.",
8
+ "since": "0.24.0",
9
+ "tags": [
10
+ "routing",
11
+ "branch",
12
+ "warns"
13
+ ],
14
+ "input": {
15
+ "schema": {
16
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
17
+ "version": 1,
18
+ "graph": {
19
+ "nodes": [
20
+ {
21
+ "id": "t",
22
+ "kind": "manual_trigger",
23
+ "position": {
24
+ "x": 0,
25
+ "y": 0
26
+ }
27
+ },
28
+ {
29
+ "id": "b",
30
+ "kind": "branch",
31
+ "position": {
32
+ "x": 0,
33
+ "y": 0
34
+ },
35
+ "config": {
36
+ "condition": "{{ $json.urgent }}"
37
+ }
38
+ },
39
+ {
40
+ "id": "yes",
41
+ "kind": "log",
42
+ "position": {
43
+ "x": 0,
44
+ "y": 0
45
+ },
46
+ "config": {
47
+ "message": "true side"
48
+ }
49
+ },
50
+ {
51
+ "id": "no",
52
+ "kind": "log",
53
+ "position": {
54
+ "x": 0,
55
+ "y": 0
56
+ },
57
+ "config": {
58
+ "message": "false side"
59
+ }
60
+ }
61
+ ],
62
+ "edges": [
63
+ {
64
+ "id": "e1",
65
+ "source": "t",
66
+ "target": "b"
67
+ },
68
+ {
69
+ "id": "e2",
70
+ "source": "b",
71
+ "target": "yes",
72
+ "sourceHandle": "true"
73
+ },
74
+ {
75
+ "id": "e3",
76
+ "source": "b",
77
+ "target": "no",
78
+ "sourceHandle": "false"
79
+ }
80
+ ]
81
+ }
82
+ },
83
+ "initialInputs": {
84
+ "t": {
85
+ "other": true
86
+ }
87
+ }
88
+ },
89
+ "expected": [
90
+ {
91
+ "nodeId": "b",
92
+ "message": "Node b took the \"false\" port because `condition` resolved to NOTHING — the path $json.urgent names no field on this node's inputs. That is not the same as a false condition: the route was decided by an absent value rather than by the data.",
93
+ "detail": {
94
+ "node": "b",
95
+ "configKey": "condition",
96
+ "path": "$json.urgent",
97
+ "tookPort": "false"
98
+ }
99
+ }
100
+ ],
101
+ "notes": "The failure flabs found: a correct triage graph whose urgency check named a field that did not resolve, so every request, including one reporting total payment failure, was routed as non-urgent. Routing is unchanged -- it still takes `false` -- and the warning supplies the reason."
102
+ },
103
+ {
104
+ "id": "0002-branch-condition-legitimately-false",
105
+ "title": "A condition that resolves to false is silent: most branches ever run take false honestly.",
106
+ "since": "0.24.0",
107
+ "tags": [
108
+ "routing",
109
+ "branch",
110
+ "silent"
111
+ ],
112
+ "input": {
113
+ "schema": {
114
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
115
+ "version": 1,
116
+ "graph": {
117
+ "nodes": [
118
+ {
119
+ "id": "t",
120
+ "kind": "manual_trigger",
121
+ "position": {
122
+ "x": 0,
123
+ "y": 0
124
+ }
125
+ },
126
+ {
127
+ "id": "b",
128
+ "kind": "branch",
129
+ "position": {
130
+ "x": 0,
131
+ "y": 0
132
+ },
133
+ "config": {
134
+ "condition": "{{ $json.urgent }}"
135
+ }
136
+ },
137
+ {
138
+ "id": "yes",
139
+ "kind": "log",
140
+ "position": {
141
+ "x": 0,
142
+ "y": 0
143
+ },
144
+ "config": {
145
+ "message": "true side"
146
+ }
147
+ },
148
+ {
149
+ "id": "no",
150
+ "kind": "log",
151
+ "position": {
152
+ "x": 0,
153
+ "y": 0
154
+ },
155
+ "config": {
156
+ "message": "false side"
157
+ }
158
+ }
159
+ ],
160
+ "edges": [
161
+ {
162
+ "id": "e1",
163
+ "source": "t",
164
+ "target": "b"
165
+ },
166
+ {
167
+ "id": "e2",
168
+ "source": "b",
169
+ "target": "yes",
170
+ "sourceHandle": "true"
171
+ },
172
+ {
173
+ "id": "e3",
174
+ "source": "b",
175
+ "target": "no",
176
+ "sourceHandle": "false"
177
+ }
178
+ ]
179
+ }
180
+ },
181
+ "initialInputs": {
182
+ "t": {
183
+ "urgent": false
184
+ }
185
+ }
186
+ },
187
+ "expected": [],
188
+ "notes": "The discipline row. A warning that fires on ordinary branching is noise, and noise is how a real warning stops being read."
189
+ },
190
+ {
191
+ "id": "0003-branch-condition-resolves-to-null",
192
+ "title": "A key that exists holding null is RESOLVED, and is silent.",
193
+ "since": "0.24.0",
194
+ "tags": [
195
+ "routing",
196
+ "branch",
197
+ "silent",
198
+ "absent-vs-null"
199
+ ],
200
+ "input": {
201
+ "schema": {
202
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
203
+ "version": 1,
204
+ "graph": {
205
+ "nodes": [
206
+ {
207
+ "id": "t",
208
+ "kind": "manual_trigger",
209
+ "position": {
210
+ "x": 0,
211
+ "y": 0
212
+ }
213
+ },
214
+ {
215
+ "id": "b",
216
+ "kind": "branch",
217
+ "position": {
218
+ "x": 0,
219
+ "y": 0
220
+ },
221
+ "config": {
222
+ "condition": "{{ $json.urgent }}"
223
+ }
224
+ },
225
+ {
226
+ "id": "yes",
227
+ "kind": "log",
228
+ "position": {
229
+ "x": 0,
230
+ "y": 0
231
+ },
232
+ "config": {
233
+ "message": "true side"
234
+ }
235
+ },
236
+ {
237
+ "id": "no",
238
+ "kind": "log",
239
+ "position": {
240
+ "x": 0,
241
+ "y": 0
242
+ },
243
+ "config": {
244
+ "message": "false side"
245
+ }
246
+ }
247
+ ],
248
+ "edges": [
249
+ {
250
+ "id": "e1",
251
+ "source": "t",
252
+ "target": "b"
253
+ },
254
+ {
255
+ "id": "e2",
256
+ "source": "b",
257
+ "target": "yes",
258
+ "sourceHandle": "true"
259
+ },
260
+ {
261
+ "id": "e3",
262
+ "source": "b",
263
+ "target": "no",
264
+ "sourceHandle": "false"
265
+ }
266
+ ]
267
+ }
268
+ },
269
+ "initialInputs": {
270
+ "t": {
271
+ "urgent": null
272
+ }
273
+ }
274
+ },
275
+ "expected": [],
276
+ "notes": "Absent and null are different answers. An implementation that tests the resolved VALUE for null instead of asking whether the path resolved warns here, and that is the collapse this whole table exists to prevent."
277
+ },
278
+ {
279
+ "id": "0004-branch-condition-mixes-text-and-an-expression",
280
+ "title": "A condition that mixes literal text with an unresolved expression is interpolation, not routing, and is silent.",
281
+ "since": "0.24.0",
282
+ "tags": [
283
+ "routing",
284
+ "branch",
285
+ "silent"
286
+ ],
287
+ "input": {
288
+ "schema": {
289
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
290
+ "version": 1,
291
+ "graph": {
292
+ "nodes": [
293
+ {
294
+ "id": "t",
295
+ "kind": "manual_trigger",
296
+ "position": {
297
+ "x": 0,
298
+ "y": 0
299
+ }
300
+ },
301
+ {
302
+ "id": "b",
303
+ "kind": "branch",
304
+ "position": {
305
+ "x": 0,
306
+ "y": 0
307
+ },
308
+ "config": {
309
+ "condition": "urgent is {{ $json.nope }}"
310
+ }
311
+ },
312
+ {
313
+ "id": "yes",
314
+ "kind": "log",
315
+ "position": {
316
+ "x": 0,
317
+ "y": 0
318
+ },
319
+ "config": {
320
+ "message": "true side"
321
+ }
322
+ },
323
+ {
324
+ "id": "no",
325
+ "kind": "log",
326
+ "position": {
327
+ "x": 0,
328
+ "y": 0
329
+ },
330
+ "config": {
331
+ "message": "false side"
332
+ }
333
+ }
334
+ ],
335
+ "edges": [
336
+ {
337
+ "id": "e1",
338
+ "source": "t",
339
+ "target": "b"
340
+ },
341
+ {
342
+ "id": "e2",
343
+ "source": "b",
344
+ "target": "yes",
345
+ "sourceHandle": "true"
346
+ },
347
+ {
348
+ "id": "e3",
349
+ "source": "b",
350
+ "target": "no",
351
+ "sourceHandle": "false"
352
+ }
353
+ ]
354
+ }
355
+ },
356
+ "initialInputs": {
357
+ "t": {
358
+ "x": 1
359
+ }
360
+ }
361
+ },
362
+ "expected": []
363
+ },
364
+ {
365
+ "id": "0005-adjacent-references-are-not-one-missing-path",
366
+ "title": "`{{ a }}{{ b }}` is two references, never one path spanning `}}{{`, so even when neither resolves it is not reported as a missing field.",
367
+ "since": "0.24.0",
368
+ "tags": [
369
+ "routing",
370
+ "branch",
371
+ "silent"
372
+ ],
373
+ "input": {
374
+ "schema": {
375
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
376
+ "version": 1,
377
+ "graph": {
378
+ "nodes": [
379
+ {
380
+ "id": "t",
381
+ "kind": "manual_trigger",
382
+ "position": {
383
+ "x": 0,
384
+ "y": 0
385
+ }
386
+ },
387
+ {
388
+ "id": "b",
389
+ "kind": "branch",
390
+ "position": {
391
+ "x": 0,
392
+ "y": 0
393
+ },
394
+ "config": {
395
+ "condition": "{{ $json.a }}{{ $json.b }}"
396
+ }
397
+ },
398
+ {
399
+ "id": "yes",
400
+ "kind": "log",
401
+ "position": {
402
+ "x": 0,
403
+ "y": 0
404
+ },
405
+ "config": {
406
+ "message": "true side"
407
+ }
408
+ },
409
+ {
410
+ "id": "no",
411
+ "kind": "log",
412
+ "position": {
413
+ "x": 0,
414
+ "y": 0
415
+ },
416
+ "config": {
417
+ "message": "false side"
418
+ }
419
+ }
420
+ ],
421
+ "edges": [
422
+ {
423
+ "id": "e1",
424
+ "source": "t",
425
+ "target": "b"
426
+ },
427
+ {
428
+ "id": "e2",
429
+ "source": "b",
430
+ "target": "yes",
431
+ "sourceHandle": "true"
432
+ },
433
+ {
434
+ "id": "e3",
435
+ "source": "b",
436
+ "target": "no",
437
+ "sourceHandle": "false"
438
+ }
439
+ ]
440
+ }
441
+ },
442
+ "initialInputs": {
443
+ "t": {
444
+ "x": 1
445
+ }
446
+ }
447
+ },
448
+ "expected": [],
449
+ "notes": "Before fancy-flow-php#16 the whole-string branch read this as ONE path, `$json.a }}{{ $json.b`. A diagnostic built on that reading would name a field no graph could have."
450
+ },
451
+ {
452
+ "id": "0006-switch-routes-on-an-unresolved-value",
453
+ "title": "A switch_case whose value resolves to nothing warns that it fell to `default`, naming `value` as the config key.",
454
+ "since": "0.24.0",
455
+ "tags": [
456
+ "routing",
457
+ "switch_case",
458
+ "warns"
459
+ ],
460
+ "input": {
461
+ "schema": {
462
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
463
+ "version": 1,
464
+ "graph": {
465
+ "nodes": [
466
+ {
467
+ "id": "t",
468
+ "kind": "manual_trigger",
469
+ "position": {
470
+ "x": 0,
471
+ "y": 0
472
+ }
473
+ },
474
+ {
475
+ "id": "s",
476
+ "kind": "switch_case",
477
+ "position": {
478
+ "x": 0,
479
+ "y": 0
480
+ },
481
+ "config": {
482
+ "value": "{{ $json.lane }}",
483
+ "cases": {
484
+ "billing": "case_a"
485
+ }
486
+ }
487
+ },
488
+ {
489
+ "id": "to_case_a",
490
+ "kind": "log",
491
+ "position": {
492
+ "x": 0,
493
+ "y": 0
494
+ },
495
+ "config": {
496
+ "message": "case_a"
497
+ }
498
+ },
499
+ {
500
+ "id": "to_default",
501
+ "kind": "log",
502
+ "position": {
503
+ "x": 0,
504
+ "y": 0
505
+ },
506
+ "config": {
507
+ "message": "default"
508
+ }
509
+ }
510
+ ],
511
+ "edges": [
512
+ {
513
+ "id": "e1",
514
+ "source": "t",
515
+ "target": "s"
516
+ },
517
+ {
518
+ "id": "e2",
519
+ "source": "s",
520
+ "target": "to_case_a",
521
+ "sourceHandle": "case_a"
522
+ },
523
+ {
524
+ "id": "e3",
525
+ "source": "s",
526
+ "target": "to_default",
527
+ "sourceHandle": "default"
528
+ }
529
+ ]
530
+ }
531
+ },
532
+ "initialInputs": {
533
+ "t": {
534
+ "something_else": "billing"
535
+ }
536
+ }
537
+ },
538
+ "expected": [
539
+ {
540
+ "nodeId": "s",
541
+ "message": "Node s took the \"default\" port because `value` resolved to NOTHING — the path $json.lane names no field on this node's inputs. That is not the same as a false condition: the route was decided by an absent value rather than by the data.",
542
+ "detail": {
543
+ "node": "s",
544
+ "configKey": "value",
545
+ "path": "$json.lane",
546
+ "tookPort": "default"
547
+ }
548
+ }
549
+ ]
550
+ },
551
+ {
552
+ "id": "0007-switch-value-matches-no-case",
553
+ "title": "A switch_case value that resolves but matches no case is what `default` is FOR, and is silent.",
554
+ "since": "0.24.0",
555
+ "tags": [
556
+ "routing",
557
+ "switch_case",
558
+ "silent"
559
+ ],
560
+ "input": {
561
+ "schema": {
562
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
563
+ "version": 1,
564
+ "graph": {
565
+ "nodes": [
566
+ {
567
+ "id": "t",
568
+ "kind": "manual_trigger",
569
+ "position": {
570
+ "x": 0,
571
+ "y": 0
572
+ }
573
+ },
574
+ {
575
+ "id": "s",
576
+ "kind": "switch_case",
577
+ "position": {
578
+ "x": 0,
579
+ "y": 0
580
+ },
581
+ "config": {
582
+ "value": "{{ $json.lane }}",
583
+ "cases": {
584
+ "billing": "case_a"
585
+ }
586
+ }
587
+ },
588
+ {
589
+ "id": "to_case_a",
590
+ "kind": "log",
591
+ "position": {
592
+ "x": 0,
593
+ "y": 0
594
+ },
595
+ "config": {
596
+ "message": "case_a"
597
+ }
598
+ },
599
+ {
600
+ "id": "to_default",
601
+ "kind": "log",
602
+ "position": {
603
+ "x": 0,
604
+ "y": 0
605
+ },
606
+ "config": {
607
+ "message": "default"
608
+ }
609
+ }
610
+ ],
611
+ "edges": [
612
+ {
613
+ "id": "e1",
614
+ "source": "t",
615
+ "target": "s"
616
+ },
617
+ {
618
+ "id": "e2",
619
+ "source": "s",
620
+ "target": "to_case_a",
621
+ "sourceHandle": "case_a"
622
+ },
623
+ {
624
+ "id": "e3",
625
+ "source": "s",
626
+ "target": "to_default",
627
+ "sourceHandle": "default"
628
+ }
629
+ ]
630
+ }
631
+ },
632
+ "initialInputs": {
633
+ "t": {
634
+ "lane": "unmapped"
635
+ }
636
+ }
637
+ },
638
+ "expected": []
639
+ },
640
+ {
641
+ "id": "0008-edge-names-a-port-its-source-never-publishes",
642
+ "title": "An edge whose sourceHandle names no port of a node that completed warns: edge id first, the consequence, what is available, and the remedy.",
643
+ "since": "0.24.0",
644
+ "tags": [
645
+ "undelivered-edge",
646
+ "warns"
647
+ ],
648
+ "input": {
649
+ "schema": {
650
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
651
+ "version": 1,
652
+ "graph": {
653
+ "nodes": [
654
+ {
655
+ "id": "t",
656
+ "kind": "manual_trigger",
657
+ "position": {
658
+ "x": 0,
659
+ "y": 0
660
+ }
661
+ },
662
+ {
663
+ "id": "tf",
664
+ "kind": "transform",
665
+ "position": {
666
+ "x": 0,
667
+ "y": 0
668
+ },
669
+ "config": {
670
+ "expression": "{{ $json.name }}"
671
+ }
672
+ },
673
+ {
674
+ "id": "o",
675
+ "kind": "output",
676
+ "position": {
677
+ "x": 0,
678
+ "y": 0
679
+ }
680
+ }
681
+ ],
682
+ "edges": [
683
+ {
684
+ "id": "e1",
685
+ "source": "t",
686
+ "target": "tf"
687
+ },
688
+ {
689
+ "id": "e2",
690
+ "source": "tf",
691
+ "target": "o",
692
+ "sourceHandle": "result"
693
+ }
694
+ ]
695
+ }
696
+ },
697
+ "initialInputs": {
698
+ "t": {
699
+ "name": "Ada"
700
+ }
701
+ }
702
+ },
703
+ "expected": [
704
+ {
705
+ "nodeId": "o",
706
+ "message": "Edge e2 reads port \"result\" from node tf, which never publishes it — nothing would reach o at run time. Available: out. Leave sourceHandle off to read the node's output.",
707
+ "detail": {
708
+ "edge": "e2",
709
+ "source": "tf",
710
+ "sourceHandle": "result"
711
+ }
712
+ }
713
+ ],
714
+ "notes": "The warning is reported against the TARGET node, which is the one that will never receive anything. Its only inbound edge is the bad one, so it is skipped -- the silent shape."
715
+ },
716
+ {
717
+ "id": "0009-untaken-branch-port-is-ordinary",
718
+ "title": "The edge leaving the branch port that was NOT taken binds nothing, and is silent: that is ordinary branching.",
719
+ "since": "0.24.0",
720
+ "tags": [
721
+ "undelivered-edge",
722
+ "silent"
723
+ ],
724
+ "input": {
725
+ "schema": {
726
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
727
+ "version": 1,
728
+ "graph": {
729
+ "nodes": [
730
+ {
731
+ "id": "t",
732
+ "kind": "manual_trigger",
733
+ "position": {
734
+ "x": 0,
735
+ "y": 0
736
+ }
737
+ },
738
+ {
739
+ "id": "b",
740
+ "kind": "branch",
741
+ "position": {
742
+ "x": 0,
743
+ "y": 0
744
+ },
745
+ "config": {
746
+ "condition": "{{ $json.urgent }}"
747
+ }
748
+ },
749
+ {
750
+ "id": "yes",
751
+ "kind": "log",
752
+ "position": {
753
+ "x": 0,
754
+ "y": 0
755
+ },
756
+ "config": {
757
+ "message": "true side"
758
+ }
759
+ },
760
+ {
761
+ "id": "no",
762
+ "kind": "log",
763
+ "position": {
764
+ "x": 0,
765
+ "y": 0
766
+ },
767
+ "config": {
768
+ "message": "false side"
769
+ }
770
+ }
771
+ ],
772
+ "edges": [
773
+ {
774
+ "id": "e1",
775
+ "source": "t",
776
+ "target": "b"
777
+ },
778
+ {
779
+ "id": "e2",
780
+ "source": "b",
781
+ "target": "yes",
782
+ "sourceHandle": "true"
783
+ },
784
+ {
785
+ "id": "e3",
786
+ "source": "b",
787
+ "target": "no",
788
+ "sourceHandle": "false"
789
+ }
790
+ ]
791
+ }
792
+ },
793
+ "initialInputs": {
794
+ "t": {
795
+ "urgent": true
796
+ }
797
+ }
798
+ },
799
+ "expected": [],
800
+ "notes": "Asking 'did the source publish this port?' cannot tell an untaken branch from an impossible handle -- both are absent. The rule asks whether the port is POSSIBLE for the node."
801
+ },
802
+ {
803
+ "id": "0010-inverted-switch-cases-map",
804
+ "title": "A switch_case whose cases map is inverted (port => value) warns once per edge naming a port the node can never publish.",
805
+ "since": "0.24.0",
806
+ "tags": [
807
+ "undelivered-edge",
808
+ "switch_case",
809
+ "warns"
810
+ ],
811
+ "input": {
812
+ "schema": {
813
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
814
+ "version": 1,
815
+ "graph": {
816
+ "nodes": [
817
+ {
818
+ "id": "t",
819
+ "kind": "manual_trigger",
820
+ "position": {
821
+ "x": 0,
822
+ "y": 0
823
+ }
824
+ },
825
+ {
826
+ "id": "s",
827
+ "kind": "switch_case",
828
+ "position": {
829
+ "x": 0,
830
+ "y": 0
831
+ },
832
+ "config": {
833
+ "value": "{{ $json.kind }}",
834
+ "cases": {
835
+ "case_a": "a",
836
+ "case_b": "b"
837
+ }
838
+ }
839
+ },
840
+ {
841
+ "id": "to_case_a",
842
+ "kind": "log",
843
+ "position": {
844
+ "x": 0,
845
+ "y": 0
846
+ },
847
+ "config": {
848
+ "message": "case_a"
849
+ }
850
+ },
851
+ {
852
+ "id": "to_case_b",
853
+ "kind": "log",
854
+ "position": {
855
+ "x": 0,
856
+ "y": 0
857
+ },
858
+ "config": {
859
+ "message": "case_b"
860
+ }
861
+ },
862
+ {
863
+ "id": "to_default",
864
+ "kind": "log",
865
+ "position": {
866
+ "x": 0,
867
+ "y": 0
868
+ },
869
+ "config": {
870
+ "message": "default"
871
+ }
872
+ }
873
+ ],
874
+ "edges": [
875
+ {
876
+ "id": "e1",
877
+ "source": "t",
878
+ "target": "s"
879
+ },
880
+ {
881
+ "id": "e2",
882
+ "source": "s",
883
+ "target": "to_case_a",
884
+ "sourceHandle": "case_a"
885
+ },
886
+ {
887
+ "id": "e3",
888
+ "source": "s",
889
+ "target": "to_case_b",
890
+ "sourceHandle": "case_b"
891
+ },
892
+ {
893
+ "id": "e4",
894
+ "source": "s",
895
+ "target": "to_default",
896
+ "sourceHandle": "default"
897
+ }
898
+ ]
899
+ }
900
+ },
901
+ "initialInputs": {
902
+ "t": {
903
+ "kind": "a"
904
+ }
905
+ }
906
+ },
907
+ "expected": [
908
+ {
909
+ "nodeId": "to_case_a",
910
+ "message": "Edge e2 reads port \"case_a\" from node s, which never publishes it — nothing would reach to_case_a at run time. Available: default. Leave sourceHandle off to read the node's output.",
911
+ "detail": {
912
+ "edge": "e2",
913
+ "source": "s",
914
+ "sourceHandle": "case_a"
915
+ }
916
+ },
917
+ {
918
+ "nodeId": "to_case_b",
919
+ "message": "Edge e3 reads port \"case_b\" from node s, which never publishes it — nothing would reach to_case_b at run time. Available: default. Leave sourceHandle off to read the node's output.",
920
+ "detail": {
921
+ "edge": "e3",
922
+ "source": "s",
923
+ "sourceHandle": "case_b"
924
+ }
925
+ }
926
+ ],
927
+ "notes": "Measured, not imagined: the flabs smart-routing reference graph had exactly this inversion. PHP warned about the dead edge at once; the Node, Python and Rust engines ran the same graph and said nothing (fancy-flow#17). `cases` maps VALUE => PORT, so the possible ports here are `a`, `b` and `default`."
928
+ },
929
+ {
930
+ "id": "0011-configured-third-case-is-a-real-port",
931
+ "title": "A port derived from a switch_case's own cases config is possible, so an edge from it is silent even when the run did not take it.",
932
+ "since": "0.24.0",
933
+ "tags": [
934
+ "undelivered-edge",
935
+ "switch_case",
936
+ "silent",
937
+ "config-derived-ports"
938
+ ],
939
+ "input": {
940
+ "schema": {
941
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
942
+ "version": 1,
943
+ "graph": {
944
+ "nodes": [
945
+ {
946
+ "id": "t",
947
+ "kind": "manual_trigger",
948
+ "position": {
949
+ "x": 0,
950
+ "y": 0
951
+ }
952
+ },
953
+ {
954
+ "id": "s",
955
+ "kind": "switch_case",
956
+ "position": {
957
+ "x": 0,
958
+ "y": 0
959
+ },
960
+ "config": {
961
+ "value": "{{ $json.kind }}",
962
+ "cases": {
963
+ "a": "case_a",
964
+ "b": "case_b",
965
+ "c": "case_c"
966
+ }
967
+ }
968
+ },
969
+ {
970
+ "id": "to_case_a",
971
+ "kind": "log",
972
+ "position": {
973
+ "x": 0,
974
+ "y": 0
975
+ },
976
+ "config": {
977
+ "message": "case_a"
978
+ }
979
+ },
980
+ {
981
+ "id": "to_case_b",
982
+ "kind": "log",
983
+ "position": {
984
+ "x": 0,
985
+ "y": 0
986
+ },
987
+ "config": {
988
+ "message": "case_b"
989
+ }
990
+ },
991
+ {
992
+ "id": "to_case_c",
993
+ "kind": "log",
994
+ "position": {
995
+ "x": 0,
996
+ "y": 0
997
+ },
998
+ "config": {
999
+ "message": "case_c"
1000
+ }
1001
+ },
1002
+ {
1003
+ "id": "to_default",
1004
+ "kind": "log",
1005
+ "position": {
1006
+ "x": 0,
1007
+ "y": 0
1008
+ },
1009
+ "config": {
1010
+ "message": "default"
1011
+ }
1012
+ }
1013
+ ],
1014
+ "edges": [
1015
+ {
1016
+ "id": "e1",
1017
+ "source": "t",
1018
+ "target": "s"
1019
+ },
1020
+ {
1021
+ "id": "e2",
1022
+ "source": "s",
1023
+ "target": "to_case_a",
1024
+ "sourceHandle": "case_a"
1025
+ },
1026
+ {
1027
+ "id": "e3",
1028
+ "source": "s",
1029
+ "target": "to_case_b",
1030
+ "sourceHandle": "case_b"
1031
+ },
1032
+ {
1033
+ "id": "e4",
1034
+ "source": "s",
1035
+ "target": "to_case_c",
1036
+ "sourceHandle": "case_c"
1037
+ },
1038
+ {
1039
+ "id": "e5",
1040
+ "source": "s",
1041
+ "target": "to_default",
1042
+ "sourceHandle": "default"
1043
+ }
1044
+ ]
1045
+ }
1046
+ },
1047
+ "initialInputs": {
1048
+ "t": {
1049
+ "kind": "a"
1050
+ }
1051
+ }
1052
+ },
1053
+ "expected": [],
1054
+ "notes": "The kind's static declaration lists only case_a, case_b and default. An engine that reads that instead of the node's cases reports case_c as impossible -- which is exactly what the authoring tools had invited an agent to build, and flabs caught it."
1055
+ },
1056
+ {
1057
+ "id": "0012-handle-names-a-field-not-a-port",
1058
+ "title": "When the bad handle is a FIELD the source emits, the warning says so and shows how to read the field.",
1059
+ "since": "0.24.0",
1060
+ "tags": [
1061
+ "undelivered-edge",
1062
+ "near-miss",
1063
+ "warns"
1064
+ ],
1065
+ "input": {
1066
+ "schema": {
1067
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
1068
+ "version": 1,
1069
+ "graph": {
1070
+ "nodes": [
1071
+ {
1072
+ "id": "t",
1073
+ "kind": "manual_trigger",
1074
+ "position": {
1075
+ "x": 0,
1076
+ "y": 0
1077
+ }
1078
+ },
1079
+ {
1080
+ "id": "fe",
1081
+ "kind": "for_each",
1082
+ "position": {
1083
+ "x": 0,
1084
+ "y": 0
1085
+ },
1086
+ "config": {
1087
+ "source": "{{ $json.items }}"
1088
+ }
1089
+ },
1090
+ {
1091
+ "id": "o",
1092
+ "kind": "output",
1093
+ "position": {
1094
+ "x": 0,
1095
+ "y": 0
1096
+ }
1097
+ }
1098
+ ],
1099
+ "edges": [
1100
+ {
1101
+ "id": "e1",
1102
+ "source": "t",
1103
+ "target": "fe"
1104
+ },
1105
+ {
1106
+ "id": "e2",
1107
+ "source": "fe",
1108
+ "target": "o",
1109
+ "sourceHandle": "count"
1110
+ }
1111
+ ]
1112
+ }
1113
+ },
1114
+ "initialInputs": {
1115
+ "t": {
1116
+ "items": [
1117
+ 1,
1118
+ 2
1119
+ ]
1120
+ }
1121
+ }
1122
+ },
1123
+ "expected": [
1124
+ {
1125
+ "nodeId": "o",
1126
+ "message": "Edge e2 reads port \"count\" from node fe, which never publishes it — nothing would reach o at run time. Available: item, done. Note: \"count\" is a FIELD this node emits, not a port — read it downstream as {{ in.count }} rather than naming it as a source handle. Leave sourceHandle off to read the node's output.",
1127
+ "detail": {
1128
+ "edge": "e2",
1129
+ "source": "fe",
1130
+ "sourceHandle": "count"
1131
+ }
1132
+ }
1133
+ ],
1134
+ "notes": "`count` is in for_each's declared output shape. Nearly every occurrence is an agent reaching for a field name where a port belongs, and naming that turns a correction into an explanation."
1135
+ },
1136
+ {
1137
+ "id": "0013-bad-edge-beside-a-live-one",
1138
+ "title": "A bad edge into a node that ALSO has a live inbound edge still warns, although the node runs.",
1139
+ "since": "0.24.0",
1140
+ "tags": [
1141
+ "undelivered-edge",
1142
+ "merge",
1143
+ "warns"
1144
+ ],
1145
+ "input": {
1146
+ "schema": {
1147
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
1148
+ "version": 1,
1149
+ "graph": {
1150
+ "nodes": [
1151
+ {
1152
+ "id": "t",
1153
+ "kind": "manual_trigger",
1154
+ "position": {
1155
+ "x": 0,
1156
+ "y": 0
1157
+ }
1158
+ },
1159
+ {
1160
+ "id": "a",
1161
+ "kind": "transform",
1162
+ "position": {
1163
+ "x": 0,
1164
+ "y": 0
1165
+ },
1166
+ "config": {
1167
+ "expression": "{{ $json.name }}"
1168
+ }
1169
+ },
1170
+ {
1171
+ "id": "b",
1172
+ "kind": "transform",
1173
+ "position": {
1174
+ "x": 0,
1175
+ "y": 0
1176
+ },
1177
+ "config": {
1178
+ "expression": "{{ $json.name }}"
1179
+ }
1180
+ },
1181
+ {
1182
+ "id": "m",
1183
+ "kind": "merge",
1184
+ "position": {
1185
+ "x": 0,
1186
+ "y": 0
1187
+ }
1188
+ }
1189
+ ],
1190
+ "edges": [
1191
+ {
1192
+ "id": "e1",
1193
+ "source": "t",
1194
+ "target": "a"
1195
+ },
1196
+ {
1197
+ "id": "e2",
1198
+ "source": "t",
1199
+ "target": "b"
1200
+ },
1201
+ {
1202
+ "id": "e3",
1203
+ "source": "a",
1204
+ "target": "m"
1205
+ },
1206
+ {
1207
+ "id": "e4",
1208
+ "source": "b",
1209
+ "target": "m",
1210
+ "sourceHandle": "nope"
1211
+ }
1212
+ ]
1213
+ }
1214
+ },
1215
+ "initialInputs": {
1216
+ "t": {
1217
+ "name": "Ada"
1218
+ }
1219
+ }
1220
+ },
1221
+ "expected": [
1222
+ {
1223
+ "nodeId": "m",
1224
+ "message": "Edge e4 reads port \"nope\" from node b, which never publishes it — nothing would reach m at run time. Available: out. Leave sourceHandle off to read the node's output.",
1225
+ "detail": {
1226
+ "edge": "e4",
1227
+ "source": "b",
1228
+ "sourceHandle": "nope"
1229
+ }
1230
+ }
1231
+ ],
1232
+ "notes": "The more misleading of the two silent shapes: the node runs with that input simply missing, so a downstream template is entirely correct and renders empty. A consumer misdiagnosed two filed issues off the back of it."
1233
+ },
1234
+ {
1235
+ "id": "0014-source-that-never-ran-is-not-judged",
1236
+ "title": "An edge out of a node that was skipped is silent, whatever its handle: only a source that COMPLETED can be said to never publish a port.",
1237
+ "since": "0.24.0",
1238
+ "tags": [
1239
+ "undelivered-edge",
1240
+ "silent"
1241
+ ],
1242
+ "input": {
1243
+ "schema": {
1244
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
1245
+ "version": 1,
1246
+ "graph": {
1247
+ "nodes": [
1248
+ {
1249
+ "id": "t",
1250
+ "kind": "manual_trigger",
1251
+ "position": {
1252
+ "x": 0,
1253
+ "y": 0
1254
+ }
1255
+ },
1256
+ {
1257
+ "id": "b",
1258
+ "kind": "branch",
1259
+ "position": {
1260
+ "x": 0,
1261
+ "y": 0
1262
+ },
1263
+ "config": {
1264
+ "condition": "{{ $json.urgent }}"
1265
+ }
1266
+ },
1267
+ {
1268
+ "id": "yes",
1269
+ "kind": "log",
1270
+ "position": {
1271
+ "x": 0,
1272
+ "y": 0
1273
+ },
1274
+ "config": {
1275
+ "message": "true side"
1276
+ }
1277
+ },
1278
+ {
1279
+ "id": "skipped",
1280
+ "kind": "transform",
1281
+ "position": {
1282
+ "x": 0,
1283
+ "y": 0
1284
+ },
1285
+ "config": {
1286
+ "expression": "{{ $json.urgent }}"
1287
+ }
1288
+ },
1289
+ {
1290
+ "id": "after",
1291
+ "kind": "output",
1292
+ "position": {
1293
+ "x": 0,
1294
+ "y": 0
1295
+ }
1296
+ }
1297
+ ],
1298
+ "edges": [
1299
+ {
1300
+ "id": "e1",
1301
+ "source": "t",
1302
+ "target": "b"
1303
+ },
1304
+ {
1305
+ "id": "e2",
1306
+ "source": "b",
1307
+ "target": "yes",
1308
+ "sourceHandle": "true"
1309
+ },
1310
+ {
1311
+ "id": "e3",
1312
+ "source": "b",
1313
+ "target": "skipped",
1314
+ "sourceHandle": "false"
1315
+ },
1316
+ {
1317
+ "id": "e4",
1318
+ "source": "skipped",
1319
+ "target": "after",
1320
+ "sourceHandle": "nope"
1321
+ }
1322
+ ]
1323
+ }
1324
+ },
1325
+ "initialInputs": {
1326
+ "t": {
1327
+ "urgent": true
1328
+ }
1329
+ }
1330
+ },
1331
+ "expected": []
1332
+ }
1333
+ ]
1334
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/run-diagnostics",
4
+ "title": "A graph that runs and delivers nothing must say so",
5
+ "since": "0.24.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "runDiagnostics(schema: WorkflowSchema, initialInputs: object) -> Array<{ nodeId: string|null, message: string, detail: object }>",
10
+ "summary": "Import a WorkflowSchema v1 document leniently against a local built-in kind registry with the structural kinds registered, run it with the built-in offline executors, and return every `log` event at level `warn` as { nodeId, message, detail }, SORTED by `message` (code-point order). Two warnings are pinned. (1) UNDELIVERED EDGE: before deciding whether a node runs, for each inbound edge whose source COMPLETED, whose port was not published, and whose `sourceHandle` (default `out`) is not among the ports the source could POSSIBLY publish, warn against the TARGET node with detail { edge, source, sourceHandle }. (2) ROUTE ON AN UNRESOLVED PATH: when `branch` (config key `condition`) or `switch_case` (config key `value`) holds a single whole `{{ path }}` whose path does not resolve against the node's inputs, warn against that node with detail { node, configKey, path, tookPort }. Routing itself is unchanged.",
11
+ "reference": "php",
12
+ "referenceNote": "Goldens produced by fancy-flow-php 0.52.2's FlowRunner (`undeliveredEdgeMessage`, `possiblePortIds` via `PortResolution::possible`) and `Nodes/Support/RoutingDiagnostics::warnIfUnresolved`, then reviewed row by row. PHP is the only runtime that emitted either warning when this table was written (fancy-flow#17).",
13
+ "implementations": [
14
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Engine\\FlowRunner" },
15
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "runFlow" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.engine.FlowRunner" },
17
+ { "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::engine::FlowRunner" }
18
+ ]
19
+ },
20
+ "notes": [
21
+ "MEASURED BEFORE IT WAS WRITTEN DOWN. The flabs smart-routing reference graph carried an inverted switch_case map. PHP warned about the dead edge at once; the Node, Python and Rust engines ran the identical graph, reported success and said nothing. A lab that judges a graph by those warnings could therefore catch a dead edge on one lane in four, and could only say of the other three that the engine reports nothing. Row 0010 is that graph.",
22
+ "BOTH WARNINGS ARE THE SAME SILENT FAILURE: a graph that runs, reports success, and delivers nothing down one path. Neither changes what runs. The undelivered-edge warning names the edge, the consequence, what the source did publish and the common remedy; the routing warning names the path that did not resolve and the port that was taken because of it.",
23
+ "HALF THE ROWS ARE SILENT, ON PURPOSE. A warning that fires on ordinary branching is noise, and noise is how a real warning stops being read. 0009 (the untaken branch port), 0011 (a port that exists only because of the node's own cases config), 0014 (an edge out of a node that never ran), 0002 and 0003 (a condition that is false, or resolves to null) each pin a way an implementation can warn too often. The undelivered rule asks whether a port is POSSIBLE, never whether it was published: the two cannot be told apart by looking at what arrived.",
24
+ "ABSENT IS NOT NULL. 0003 resolves to null and is silent; 0001 does not resolve and warns. An implementation that tests the resolved VALUE instead of asking whether the path RESOLVED fails 0003.",
25
+ "SORTED BY MESSAGE, NOT EMISSION ORDER. Undelivered-edge warnings are emitted as each target is reached in topological order, and runtimes may break ties between same-depth nodes differently. Ordering is `flow/graph-runs`' question; this table asks which warnings a run produces.",
26
+ "THE NEAR-MISS NOTE (0012) comes from the source kind's declared output shape: a handle that equals a field path of that shape is an agent naming a field where a port belongs, and the message says how to read the field instead. It needs the shape, which every runtime's builtin `for_each` declares.",
27
+ "There is NO discrimination probe for this suite, for the same reason as `flow/graph-runs`: the implementation under test is a whole workflow engine, which this package does not carry."
28
+ ]
29
+ }