@particle-academy/fancy-conformance 0.12.0 → 0.13.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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.12.0
1
+ 0.13.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.12.0",
3
+ "version": "0.13.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 \u2014 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,721 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/entry-points",
4
+ "cases": [
5
+ {
6
+ "id": "0101-unset-runs-every-entry-point",
7
+ "title": "Unset: every entry point runs, exactly as before the option existed.",
8
+ "since": "0.13.0",
9
+ "tags": [
10
+ "entry-points",
11
+ "compat"
12
+ ],
13
+ "input": {
14
+ "schema": {
15
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
16
+ "version": 1,
17
+ "graph": {
18
+ "nodes": [
19
+ {
20
+ "id": "t1",
21
+ "kind": "manual_trigger",
22
+ "position": {
23
+ "x": 0,
24
+ "y": 0
25
+ }
26
+ },
27
+ {
28
+ "id": "t2",
29
+ "kind": "manual_trigger",
30
+ "position": {
31
+ "x": 0,
32
+ "y": 0
33
+ }
34
+ },
35
+ {
36
+ "id": "a",
37
+ "kind": "transform",
38
+ "position": {
39
+ "x": 1,
40
+ "y": 0
41
+ },
42
+ "config": {
43
+ "expression": "{{ $json.n }}"
44
+ }
45
+ },
46
+ {
47
+ "id": "b",
48
+ "kind": "transform",
49
+ "position": {
50
+ "x": 1,
51
+ "y": 0
52
+ },
53
+ "config": {
54
+ "expression": "{{ $json.n }}"
55
+ }
56
+ },
57
+ {
58
+ "id": "m",
59
+ "kind": "output",
60
+ "position": {
61
+ "x": 2,
62
+ "y": 0
63
+ }
64
+ }
65
+ ],
66
+ "edges": [
67
+ {
68
+ "id": "e1",
69
+ "source": "t1",
70
+ "target": "a"
71
+ },
72
+ {
73
+ "id": "e2",
74
+ "source": "t2",
75
+ "target": "b"
76
+ },
77
+ {
78
+ "id": "e3",
79
+ "source": "a",
80
+ "target": "m"
81
+ },
82
+ {
83
+ "id": "e4",
84
+ "source": "b",
85
+ "target": "m"
86
+ }
87
+ ]
88
+ }
89
+ },
90
+ "initialInputs": {
91
+ "t1": {
92
+ "n": 1
93
+ },
94
+ "t2": {
95
+ "n": 2
96
+ }
97
+ },
98
+ "entryNodes": null
99
+ },
100
+ "expected": [
101
+ "a",
102
+ "b",
103
+ "m",
104
+ "t1",
105
+ "t2"
106
+ ],
107
+ "notes": "The backward-compatibility row, and the most important one here. `null` means the caller is not using the feature, and every graph written before it existed must behave identically. A change that quietly altered this would break every multi-trigger graph in the field to fix one."
108
+ },
109
+ {
110
+ "id": "0102-naming-one-entry-skips-the-other-branch",
111
+ "title": "Naming one trigger runs its branch and skips everything reachable only from the other.",
112
+ "since": "0.13.0",
113
+ "tags": [
114
+ "entry-points"
115
+ ],
116
+ "input": {
117
+ "schema": {
118
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
119
+ "version": 1,
120
+ "graph": {
121
+ "nodes": [
122
+ {
123
+ "id": "t1",
124
+ "kind": "manual_trigger",
125
+ "position": {
126
+ "x": 0,
127
+ "y": 0
128
+ }
129
+ },
130
+ {
131
+ "id": "t2",
132
+ "kind": "manual_trigger",
133
+ "position": {
134
+ "x": 0,
135
+ "y": 0
136
+ }
137
+ },
138
+ {
139
+ "id": "a",
140
+ "kind": "transform",
141
+ "position": {
142
+ "x": 1,
143
+ "y": 0
144
+ },
145
+ "config": {
146
+ "expression": "{{ $json.n }}"
147
+ }
148
+ },
149
+ {
150
+ "id": "b",
151
+ "kind": "transform",
152
+ "position": {
153
+ "x": 1,
154
+ "y": 0
155
+ },
156
+ "config": {
157
+ "expression": "{{ $json.n }}"
158
+ }
159
+ },
160
+ {
161
+ "id": "m",
162
+ "kind": "output",
163
+ "position": {
164
+ "x": 2,
165
+ "y": 0
166
+ }
167
+ }
168
+ ],
169
+ "edges": [
170
+ {
171
+ "id": "e1",
172
+ "source": "t1",
173
+ "target": "a"
174
+ },
175
+ {
176
+ "id": "e2",
177
+ "source": "t2",
178
+ "target": "b"
179
+ },
180
+ {
181
+ "id": "e3",
182
+ "source": "a",
183
+ "target": "m"
184
+ },
185
+ {
186
+ "id": "e4",
187
+ "source": "b",
188
+ "target": "m"
189
+ }
190
+ ]
191
+ }
192
+ },
193
+ "initialInputs": {
194
+ "t1": {
195
+ "n": 1
196
+ },
197
+ "t2": {
198
+ "n": 2
199
+ }
200
+ },
201
+ "entryNodes": [
202
+ "t1"
203
+ ]
204
+ },
205
+ "expected": [
206
+ "a",
207
+ "m",
208
+ "t1"
209
+ ],
210
+ "notes": "The defect, fixed. `t2` is an entry point that was not named, so it is inactive; `b` is reachable only from `t2` and therefore has no active inbound edge. No new routing logic is involved -- the existing dead-edge rule does all of it."
211
+ },
212
+ {
213
+ "id": "0103-the-mirror-direction",
214
+ "title": "The same holds for the other trigger, so the rule is not accidentally order-dependent.",
215
+ "since": "0.13.0",
216
+ "tags": [
217
+ "entry-points"
218
+ ],
219
+ "input": {
220
+ "schema": {
221
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
222
+ "version": 1,
223
+ "graph": {
224
+ "nodes": [
225
+ {
226
+ "id": "t1",
227
+ "kind": "manual_trigger",
228
+ "position": {
229
+ "x": 0,
230
+ "y": 0
231
+ }
232
+ },
233
+ {
234
+ "id": "t2",
235
+ "kind": "manual_trigger",
236
+ "position": {
237
+ "x": 0,
238
+ "y": 0
239
+ }
240
+ },
241
+ {
242
+ "id": "a",
243
+ "kind": "transform",
244
+ "position": {
245
+ "x": 1,
246
+ "y": 0
247
+ },
248
+ "config": {
249
+ "expression": "{{ $json.n }}"
250
+ }
251
+ },
252
+ {
253
+ "id": "b",
254
+ "kind": "transform",
255
+ "position": {
256
+ "x": 1,
257
+ "y": 0
258
+ },
259
+ "config": {
260
+ "expression": "{{ $json.n }}"
261
+ }
262
+ },
263
+ {
264
+ "id": "m",
265
+ "kind": "output",
266
+ "position": {
267
+ "x": 2,
268
+ "y": 0
269
+ }
270
+ }
271
+ ],
272
+ "edges": [
273
+ {
274
+ "id": "e1",
275
+ "source": "t1",
276
+ "target": "a"
277
+ },
278
+ {
279
+ "id": "e2",
280
+ "source": "t2",
281
+ "target": "b"
282
+ },
283
+ {
284
+ "id": "e3",
285
+ "source": "a",
286
+ "target": "m"
287
+ },
288
+ {
289
+ "id": "e4",
290
+ "source": "b",
291
+ "target": "m"
292
+ }
293
+ ]
294
+ }
295
+ },
296
+ "initialInputs": {
297
+ "t1": {
298
+ "n": 1
299
+ },
300
+ "t2": {
301
+ "n": 2
302
+ }
303
+ },
304
+ "entryNodes": [
305
+ "t2"
306
+ ]
307
+ },
308
+ "expected": [
309
+ "b",
310
+ "m",
311
+ "t2"
312
+ ],
313
+ "notes": "The mirror of 0102. Asserting only one direction would pass against an implementation that hard-coded the first entry point in document order, which is exactly the kind of thing that survives a review and fails on a graph whose nodes were reordered."
314
+ },
315
+ {
316
+ "id": "0104-a-merge-fed-by-both-still-runs",
317
+ "title": "A node reachable from BOTH entry points still runs when only one fires.",
318
+ "since": "0.13.0",
319
+ "tags": [
320
+ "entry-points"
321
+ ],
322
+ "input": {
323
+ "schema": {
324
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
325
+ "version": 1,
326
+ "graph": {
327
+ "nodes": [
328
+ {
329
+ "id": "t1",
330
+ "kind": "manual_trigger",
331
+ "position": {
332
+ "x": 0,
333
+ "y": 0
334
+ }
335
+ },
336
+ {
337
+ "id": "t2",
338
+ "kind": "manual_trigger",
339
+ "position": {
340
+ "x": 0,
341
+ "y": 0
342
+ }
343
+ },
344
+ {
345
+ "id": "a",
346
+ "kind": "transform",
347
+ "position": {
348
+ "x": 1,
349
+ "y": 0
350
+ },
351
+ "config": {
352
+ "expression": "{{ $json.n }}"
353
+ }
354
+ },
355
+ {
356
+ "id": "b",
357
+ "kind": "transform",
358
+ "position": {
359
+ "x": 1,
360
+ "y": 0
361
+ },
362
+ "config": {
363
+ "expression": "{{ $json.n }}"
364
+ }
365
+ },
366
+ {
367
+ "id": "m",
368
+ "kind": "output",
369
+ "position": {
370
+ "x": 2,
371
+ "y": 0
372
+ }
373
+ }
374
+ ],
375
+ "edges": [
376
+ {
377
+ "id": "e1",
378
+ "source": "t1",
379
+ "target": "a"
380
+ },
381
+ {
382
+ "id": "e2",
383
+ "source": "t2",
384
+ "target": "b"
385
+ },
386
+ {
387
+ "id": "e3",
388
+ "source": "a",
389
+ "target": "m"
390
+ },
391
+ {
392
+ "id": "e4",
393
+ "source": "b",
394
+ "target": "m"
395
+ }
396
+ ]
397
+ }
398
+ },
399
+ "initialInputs": {
400
+ "t1": {
401
+ "n": 1
402
+ },
403
+ "t2": {
404
+ "n": 2
405
+ }
406
+ },
407
+ "entryNodes": [
408
+ "t1"
409
+ ]
410
+ },
411
+ "expected": [
412
+ "a",
413
+ "m",
414
+ "t1"
415
+ ],
416
+ "notes": "The guard against over-reaching. `m` has two inbound edges and only one is active, and the merge-after-decision contract says one active inbound is enough. An implementation that required ALL inbound edges to be active would pass 0102 and silently stop every graph that converges its branches -- which is the shape consumers are told to use as the workaround for this very bug."
417
+ },
418
+ {
419
+ "id": "0105-naming-both-is-the-same-as-unset",
420
+ "title": "Naming every entry point runs everything, matching the unset case.",
421
+ "since": "0.13.0",
422
+ "tags": [
423
+ "entry-points"
424
+ ],
425
+ "input": {
426
+ "schema": {
427
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
428
+ "version": 1,
429
+ "graph": {
430
+ "nodes": [
431
+ {
432
+ "id": "t1",
433
+ "kind": "manual_trigger",
434
+ "position": {
435
+ "x": 0,
436
+ "y": 0
437
+ }
438
+ },
439
+ {
440
+ "id": "t2",
441
+ "kind": "manual_trigger",
442
+ "position": {
443
+ "x": 0,
444
+ "y": 0
445
+ }
446
+ },
447
+ {
448
+ "id": "a",
449
+ "kind": "transform",
450
+ "position": {
451
+ "x": 1,
452
+ "y": 0
453
+ },
454
+ "config": {
455
+ "expression": "{{ $json.n }}"
456
+ }
457
+ },
458
+ {
459
+ "id": "b",
460
+ "kind": "transform",
461
+ "position": {
462
+ "x": 1,
463
+ "y": 0
464
+ },
465
+ "config": {
466
+ "expression": "{{ $json.n }}"
467
+ }
468
+ },
469
+ {
470
+ "id": "m",
471
+ "kind": "output",
472
+ "position": {
473
+ "x": 2,
474
+ "y": 0
475
+ }
476
+ }
477
+ ],
478
+ "edges": [
479
+ {
480
+ "id": "e1",
481
+ "source": "t1",
482
+ "target": "a"
483
+ },
484
+ {
485
+ "id": "e2",
486
+ "source": "t2",
487
+ "target": "b"
488
+ },
489
+ {
490
+ "id": "e3",
491
+ "source": "a",
492
+ "target": "m"
493
+ },
494
+ {
495
+ "id": "e4",
496
+ "source": "b",
497
+ "target": "m"
498
+ }
499
+ ]
500
+ }
501
+ },
502
+ "initialInputs": {
503
+ "t1": {
504
+ "n": 1
505
+ },
506
+ "t2": {
507
+ "n": 2
508
+ }
509
+ },
510
+ "entryNodes": [
511
+ "t1",
512
+ "t2"
513
+ ]
514
+ },
515
+ "expected": [
516
+ "a",
517
+ "b",
518
+ "m",
519
+ "t1",
520
+ "t2"
521
+ ],
522
+ "notes": "Pins that the option SELECTS rather than restricts-by-existing: opting in and naming everything must not be subtly different from not opting in."
523
+ },
524
+ {
525
+ "id": "0106-empty-list-runs-nothing",
526
+ "title": "An empty list says no entry point is live, and nothing runs.",
527
+ "since": "0.13.0",
528
+ "tags": [
529
+ "entry-points"
530
+ ],
531
+ "input": {
532
+ "schema": {
533
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
534
+ "version": 1,
535
+ "graph": {
536
+ "nodes": [
537
+ {
538
+ "id": "t1",
539
+ "kind": "manual_trigger",
540
+ "position": {
541
+ "x": 0,
542
+ "y": 0
543
+ }
544
+ },
545
+ {
546
+ "id": "t2",
547
+ "kind": "manual_trigger",
548
+ "position": {
549
+ "x": 0,
550
+ "y": 0
551
+ }
552
+ },
553
+ {
554
+ "id": "a",
555
+ "kind": "transform",
556
+ "position": {
557
+ "x": 1,
558
+ "y": 0
559
+ },
560
+ "config": {
561
+ "expression": "{{ $json.n }}"
562
+ }
563
+ },
564
+ {
565
+ "id": "b",
566
+ "kind": "transform",
567
+ "position": {
568
+ "x": 1,
569
+ "y": 0
570
+ },
571
+ "config": {
572
+ "expression": "{{ $json.n }}"
573
+ }
574
+ },
575
+ {
576
+ "id": "m",
577
+ "kind": "output",
578
+ "position": {
579
+ "x": 2,
580
+ "y": 0
581
+ }
582
+ }
583
+ ],
584
+ "edges": [
585
+ {
586
+ "id": "e1",
587
+ "source": "t1",
588
+ "target": "a"
589
+ },
590
+ {
591
+ "id": "e2",
592
+ "source": "t2",
593
+ "target": "b"
594
+ },
595
+ {
596
+ "id": "e3",
597
+ "source": "a",
598
+ "target": "m"
599
+ },
600
+ {
601
+ "id": "e4",
602
+ "source": "b",
603
+ "target": "m"
604
+ }
605
+ ]
606
+ }
607
+ },
608
+ "initialInputs": {
609
+ "t1": {
610
+ "n": 1
611
+ },
612
+ "t2": {
613
+ "n": 2
614
+ }
615
+ },
616
+ "entryNodes": []
617
+ },
618
+ "expected": [],
619
+ "notes": "Empty is NOT unset, and this row exists so the two can never be collapsed. `null` = 'not using the feature'; `[]` = 'no entry is live'. A runtime treating `[]` as `null` would run every branch precisely when the caller asked for none."
620
+ },
621
+ {
622
+ "id": "0107-naming-a-non-entry-node-selects-no-entry",
623
+ "title": "Naming a node that HAS inbound edges names no entry point, so nothing runs.",
624
+ "since": "0.13.0",
625
+ "tags": [
626
+ "entry-points"
627
+ ],
628
+ "input": {
629
+ "schema": {
630
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
631
+ "version": 1,
632
+ "graph": {
633
+ "nodes": [
634
+ {
635
+ "id": "t1",
636
+ "kind": "manual_trigger",
637
+ "position": {
638
+ "x": 0,
639
+ "y": 0
640
+ }
641
+ },
642
+ {
643
+ "id": "t2",
644
+ "kind": "manual_trigger",
645
+ "position": {
646
+ "x": 0,
647
+ "y": 0
648
+ }
649
+ },
650
+ {
651
+ "id": "a",
652
+ "kind": "transform",
653
+ "position": {
654
+ "x": 1,
655
+ "y": 0
656
+ },
657
+ "config": {
658
+ "expression": "{{ $json.n }}"
659
+ }
660
+ },
661
+ {
662
+ "id": "b",
663
+ "kind": "transform",
664
+ "position": {
665
+ "x": 1,
666
+ "y": 0
667
+ },
668
+ "config": {
669
+ "expression": "{{ $json.n }}"
670
+ }
671
+ },
672
+ {
673
+ "id": "m",
674
+ "kind": "output",
675
+ "position": {
676
+ "x": 2,
677
+ "y": 0
678
+ }
679
+ }
680
+ ],
681
+ "edges": [
682
+ {
683
+ "id": "e1",
684
+ "source": "t1",
685
+ "target": "a"
686
+ },
687
+ {
688
+ "id": "e2",
689
+ "source": "t2",
690
+ "target": "b"
691
+ },
692
+ {
693
+ "id": "e3",
694
+ "source": "a",
695
+ "target": "m"
696
+ },
697
+ {
698
+ "id": "e4",
699
+ "source": "b",
700
+ "target": "m"
701
+ }
702
+ ]
703
+ }
704
+ },
705
+ "initialInputs": {
706
+ "t1": {
707
+ "n": 1
708
+ },
709
+ "t2": {
710
+ "n": 2
711
+ }
712
+ },
713
+ "entryNodes": [
714
+ "a"
715
+ ]
716
+ },
717
+ "expected": [],
718
+ "notes": "Surprising, deliberate, and a consequence of the rule rather than a special case: the option gates only nodes with no incoming edges, so naming `a` leaves both real entries unnamed and therefore inactive. Pinned so no runtime 'helpfully' reinterprets it into something cleverer. A host that wants a typo to be loud must validate its own ids -- the runtime cannot distinguish a mistake from a deliberate empty selection."
719
+ }
720
+ ]
721
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/entry-points",
4
+ "title": "Which entry point fired — and therefore which nodes run",
5
+ "since": "0.13.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "runEntryPoints(schema: WorkflowSchema, initialInputs: object, entryNodes: string[]|null) -> string[]",
10
+ "summary": "Import a WorkflowSchema v1 document leniently, run it with the built-in offline executors, and return the SORTED ids of the nodes that actually EXECUTED. `entryNodes` names which entry points are live: null means unset (today's behaviour), a list names the live ones. Sorted rather than in run order, because the question this suite asks is WHICH nodes ran, not in what sequence — `flow/graph-runs` already pins ordering-sensitive behaviour.",
11
+ "reference": "php",
12
+ "referenceNote": "Filed against the PHP runtime with production measurements, but the defect is in all three: a trigger has no inbound edges, and every runtime runs a node when it has no incoming edges. The rule is written here first so no runtime has to re-derive it.",
13
+ "implementations": [
14
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Runtime\\RunOptions::$entryNodes" },
15
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "RunOptions.entryNodes" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "RunOptions.entry_nodes" }
17
+ ]
18
+ },
19
+ "notes": [
20
+ "THE DEFECT THIS FIXES WAS MEASURED IN PRODUCTION, not imagined. A graph may hold more than one trigger — a `manual_trigger` for hand-testing beside the event trigger that runs it for real — and every runtime executes EVERY trigger's branch on EVERY run, because a trigger node has no inbound edges and 'no inbound edges' is precisely the readiness rule. The consumer verified it is identical under both PHP queue drivers: `FlowRunner` walks a Kahn order and `Frontier::compute` restates the same rule for the per-node driver.",
21
+ "The cost is not the trigger nodes themselves — they emit a payload nobody reads. It is everything DOWNSTREAM of a trigger that did not fire. Two failures they measured: an empty payload winning a race into a shared `transform` (workaroundable by naming handles), and — with no workaround at all — a `user_input` on the manual branch executing during an EVENT-triggered run, so the run parks asking a person to paste data the event already supplied. From outside that looks like the event trigger being ignored.",
22
+ "THE RULE: `entryNodes` marks which nodes WITH NO INCOMING EDGES are live. An entry point that is not named is treated as INACTIVE — it does not run, and the existing 'at least one active inbound edge' rule then skips everything reachable only from it. Nodes that HAVE incoming edges are unaffected by the option and follow the rules they always did. That is the whole change: no new routing logic, and both schedulers already have the seam at their `incoming === []` check.",
23
+ "UNSET IS NOT THE SAME AS EMPTY, and 0101 versus 0106 pin the difference. `null` means the caller is not using the feature and gets today's behaviour exactly — this is what keeps every existing graph working. `[]` means the caller says no entry point is live, and nothing runs. Collapsing the two would either break every current consumer or make 'run nothing' unexpressible.",
24
+ "0107 is the surprising one and is deliberate. Naming a node that HAS inbound edges names no entry point, so every real entry is inactive and the graph runs nothing. That falls out of the rule rather than being a special case, and it is pinned so no runtime 'helpfully' reinterprets it. A host that wants a mistake here to be loud should validate its own ids before calling — the runtime cannot tell a typo from a deliberate empty selection."
25
+ ]
26
+ }