@particle-academy/fancy-conformance 0.18.0 → 0.19.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.18.0
1
+ 0.19.0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.18.0",
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.",
3
+ "version": "0.19.0",
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",
7
7
  "url": "git+https://github.com/Particle-Academy/fancy-conformance.git"
@@ -0,0 +1,393 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/kind-declaration-surface",
4
+ "cases": [
5
+ {
6
+ "id": "0101-api_request-declares-its-fields",
7
+ "title": "`api_request` declares the fields its executor returns.",
8
+ "since": "0.19.0",
9
+ "tags": [
10
+ "surface",
11
+ "output-shape"
12
+ ],
13
+ "input": {
14
+ "kind": "api_request",
15
+ "config": {}
16
+ },
17
+ "expected": {
18
+ "outputShape": [
19
+ "body",
20
+ "headers",
21
+ "status"
22
+ ],
23
+ "emits": null
24
+ }
25
+ },
26
+ {
27
+ "id": "0102-embed_search-declares-its-fields",
28
+ "title": "`embed_search` declares the fields its executor returns.",
29
+ "since": "0.19.0",
30
+ "tags": [
31
+ "surface",
32
+ "output-shape"
33
+ ],
34
+ "input": {
35
+ "kind": "embed_search",
36
+ "config": {}
37
+ },
38
+ "expected": {
39
+ "outputShape": [
40
+ "matches",
41
+ "query"
42
+ ],
43
+ "emits": null
44
+ }
45
+ },
46
+ {
47
+ "id": "0103-llm_router-declares-its-fields",
48
+ "title": "`llm_router` declares the fields its executor returns.",
49
+ "since": "0.19.0",
50
+ "tags": [
51
+ "surface",
52
+ "output-shape"
53
+ ],
54
+ "input": {
55
+ "kind": "llm_router",
56
+ "config": {}
57
+ },
58
+ "expected": {
59
+ "outputShape": [
60
+ "input",
61
+ "reason",
62
+ "route"
63
+ ],
64
+ "emits": null
65
+ }
66
+ },
67
+ {
68
+ "id": "0104-notify-declares-its-fields",
69
+ "title": "`notify` declares the fields its executor returns.",
70
+ "since": "0.19.0",
71
+ "tags": [
72
+ "surface",
73
+ "output-shape"
74
+ ],
75
+ "input": {
76
+ "kind": "notify",
77
+ "config": {}
78
+ },
79
+ "expected": {
80
+ "outputShape": [
81
+ "channel",
82
+ "message",
83
+ "sent",
84
+ "to"
85
+ ],
86
+ "emits": null
87
+ }
88
+ },
89
+ {
90
+ "id": "0105-webhook_out-declares-its-fields",
91
+ "title": "`webhook_out` declares the fields its executor returns.",
92
+ "since": "0.19.0",
93
+ "tags": [
94
+ "surface",
95
+ "output-shape"
96
+ ],
97
+ "input": {
98
+ "kind": "webhook_out",
99
+ "config": {}
100
+ },
101
+ "expected": {
102
+ "outputShape": [
103
+ "response",
104
+ "sent",
105
+ "status"
106
+ ],
107
+ "emits": null
108
+ }
109
+ },
110
+ {
111
+ "id": "0106-for_each-declares-its-fields",
112
+ "title": "`for_each` declares the fields its executor returns.",
113
+ "since": "0.19.0",
114
+ "tags": [
115
+ "surface",
116
+ "output-shape"
117
+ ],
118
+ "input": {
119
+ "kind": "for_each",
120
+ "config": {}
121
+ },
122
+ "expected": {
123
+ "outputShape": [
124
+ "count",
125
+ "items"
126
+ ],
127
+ "emits": null
128
+ }
129
+ },
130
+ {
131
+ "id": "0107-log-declares-its-fields",
132
+ "title": "`log` declares the fields its executor returns.",
133
+ "since": "0.19.0",
134
+ "tags": [
135
+ "surface",
136
+ "output-shape"
137
+ ],
138
+ "input": {
139
+ "kind": "log",
140
+ "config": {}
141
+ },
142
+ "expected": {
143
+ "outputShape": [
144
+ "level",
145
+ "logged"
146
+ ],
147
+ "emits": null
148
+ }
149
+ },
150
+ {
151
+ "id": "0201-pass-through-declares-nothing",
152
+ "title": "A pass-through kind declares NO field shape — null, never an empty list.",
153
+ "since": "0.19.0",
154
+ "tags": [
155
+ "surface",
156
+ "three-state"
157
+ ],
158
+ "input": {
159
+ "kind": "branch",
160
+ "config": {}
161
+ },
162
+ "expected": {
163
+ "outputShape": null,
164
+ "emits": "input"
165
+ },
166
+ "note": "null means nobody declared. Reading it as [] refuses every valid reference on the kind."
167
+ },
168
+ {
169
+ "id": "0202-merge-concat-declares-nothing",
170
+ "title": "`merge` in concat mode declares neither a shape nor a relation.",
171
+ "since": "0.19.0",
172
+ "tags": [
173
+ "surface",
174
+ "three-state"
175
+ ],
176
+ "input": {
177
+ "kind": "merge",
178
+ "config": {
179
+ "mode": "concat"
180
+ }
181
+ },
182
+ "expected": {
183
+ "outputShape": null,
184
+ "emits": null
185
+ },
186
+ "note": "It emits a LIST, whose elements are not addressable as top-level fields. [] would claim 'emits no fields', which is false and would refuse every reference."
187
+ },
188
+ {
189
+ "id": "0203-llm-call-is-dynamic",
190
+ "title": "`llm_call` is config-dependent, and says so rather than staying silent.",
191
+ "since": "0.19.0",
192
+ "tags": [
193
+ "surface",
194
+ "three-state",
195
+ "dynamic"
196
+ ],
197
+ "input": {
198
+ "kind": "llm_call",
199
+ "config": {}
200
+ },
201
+ "expected": {
202
+ "outputShape": "dynamic",
203
+ "emits": null
204
+ },
205
+ "note": "A consumer must tell 'declared, unresolvable here' from 'nobody declared': the first means ask the host, the second means fall back to your own knowledge. Collapsing them makes a fixed table answer a config-dependent question."
206
+ },
207
+ {
208
+ "id": "0204-user-input-is-dynamic",
209
+ "title": "`user_input` emits the keys its author defined, so it is dynamic too.",
210
+ "since": "0.19.0",
211
+ "tags": [
212
+ "surface",
213
+ "three-state",
214
+ "dynamic"
215
+ ],
216
+ "input": {
217
+ "kind": "user_input",
218
+ "config": {}
219
+ },
220
+ "expected": {
221
+ "outputShape": "dynamic",
222
+ "emits": null
223
+ }
224
+ },
225
+ {
226
+ "id": "0301-switch_case-relation",
227
+ "title": "`switch_case` declares the relation `input`.",
228
+ "since": "0.19.0",
229
+ "tags": [
230
+ "surface",
231
+ "emits"
232
+ ],
233
+ "input": {
234
+ "kind": "switch_case",
235
+ "config": {}
236
+ },
237
+ "expected": {
238
+ "outputShape": null,
239
+ "emits": "input"
240
+ }
241
+ },
242
+ {
243
+ "id": "0302-output-relation",
244
+ "title": "`output` declares the relation `input`.",
245
+ "since": "0.19.0",
246
+ "tags": [
247
+ "surface",
248
+ "emits"
249
+ ],
250
+ "input": {
251
+ "kind": "output",
252
+ "config": {}
253
+ },
254
+ "expected": {
255
+ "outputShape": null,
256
+ "emits": "input"
257
+ }
258
+ },
259
+ {
260
+ "id": "0303-human_approval-relation",
261
+ "title": "`human_approval` declares the relation `input`.",
262
+ "since": "0.19.0",
263
+ "tags": [
264
+ "surface",
265
+ "emits"
266
+ ],
267
+ "input": {
268
+ "kind": "human_approval",
269
+ "config": {}
270
+ },
271
+ "expected": {
272
+ "outputShape": null,
273
+ "emits": "input"
274
+ }
275
+ },
276
+ {
277
+ "id": "0304-manual_trigger-relation",
278
+ "title": "`manual_trigger` declares the relation `input`.",
279
+ "since": "0.19.0",
280
+ "tags": [
281
+ "surface",
282
+ "emits"
283
+ ],
284
+ "input": {
285
+ "kind": "manual_trigger",
286
+ "config": {}
287
+ },
288
+ "expected": {
289
+ "outputShape": null,
290
+ "emits": "input"
291
+ }
292
+ },
293
+ {
294
+ "id": "0305-variable-relation",
295
+ "title": "`variable` declares the relation `expression:value`.",
296
+ "since": "0.19.0",
297
+ "tags": [
298
+ "surface",
299
+ "emits"
300
+ ],
301
+ "input": {
302
+ "kind": "variable",
303
+ "config": {}
304
+ },
305
+ "expected": {
306
+ "outputShape": null,
307
+ "emits": "expression:value"
308
+ }
309
+ },
310
+ {
311
+ "id": "0306-expression-relation-names-its-config-key",
312
+ "title": "An expression relation carries the config key it reads — `value`, not `expression`.",
313
+ "since": "0.19.0",
314
+ "tags": [
315
+ "surface",
316
+ "emits",
317
+ "expression"
318
+ ],
319
+ "input": {
320
+ "kind": "variable",
321
+ "config": {}
322
+ },
323
+ "expected": {
324
+ "outputShape": null,
325
+ "emits": "expression:value"
326
+ },
327
+ "note": "A consumer hardcoding 'the field called expression' has copied the runtime's knowledge one level down, which is what carrying the key removes. `transform` reads `expression`; `variable` reads `value`."
328
+ },
329
+ {
330
+ "id": "0401-wait-nests-so-it-declares-a-list-and-no-relation",
331
+ "title": "`wait` NESTS its input under a key, so it declares fields and NO relation.",
332
+ "since": "0.19.0",
333
+ "tags": [
334
+ "surface",
335
+ "emits",
336
+ "merge-or-nest"
337
+ ],
338
+ "input": {
339
+ "kind": "wait",
340
+ "config": {}
341
+ },
342
+ "expected": {
343
+ "outputShape": [
344
+ "duration",
345
+ "input",
346
+ "waited"
347
+ ],
348
+ "emits": null
349
+ },
350
+ "note": "A relation with no destination can only express a TOP-LEVEL merge. emits:'input' here would make a reader accept {{ in.<any inbound field> }} at top level, which resolves to nothing at run time. This row is why merge-or-nest must be read off the executor before a relation is assigned."
351
+ },
352
+ {
353
+ "id": "0402-schedule-trigger-composes-a-list-with-a-merge",
354
+ "title": "`schedule_trigger` declares BOTH, because its merge is genuinely top-level.",
355
+ "since": "0.19.0",
356
+ "tags": [
357
+ "surface",
358
+ "emits",
359
+ "merge-or-nest"
360
+ ],
361
+ "input": {
362
+ "kind": "schedule_trigger",
363
+ "config": {}
364
+ },
365
+ "expected": {
366
+ "outputShape": [
367
+ "cron",
368
+ "timezone"
369
+ ],
370
+ "emits": "inputs-merged"
371
+ },
372
+ "note": "Correct precisely where `wait` is not. It was undeclared until the relation existed: a partial ['cron','timezone'] list with nothing to say the inputs also merge is a false-rejection generator."
373
+ },
374
+ {
375
+ "id": "0403-webhook-trigger-is-data-dependent",
376
+ "title": "`webhook_trigger` declares no relation — its choice is DATA-dependent.",
377
+ "since": "0.19.0",
378
+ "tags": [
379
+ "surface",
380
+ "emits"
381
+ ],
382
+ "input": {
383
+ "kind": "webhook_trigger",
384
+ "config": {}
385
+ },
386
+ "expected": {
387
+ "outputShape": null,
388
+ "emits": null
389
+ },
390
+ "note": "`inputs.payload ?? inputs` cannot be answered from config, so no relation is honest. Under-claiming is free."
391
+ }
392
+ ]
393
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/kind-declaration-surface",
4
+ "title": "Every runtime's NodeKind exposes the same declaration fields, with the same shape",
5
+ "since": "0.19.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "declarationSurface(kindId: string) -> { outputShape: string[]|null|\"dynamic\", emits: string|null }",
10
+ "summary": "Look a builtin kind up in this runtime's registry and report what it DECLARES: the SET of field paths its outputShape names (or null when undeclared, or the marker \"dynamic\" when it is config-dependent and unresolvable here), and its emits relation (or null). Field paths are compared as a SET, never as an ordered list — every runtime returns them from a map, so order is not semantic and asserting on it would report a divergence that is not one.",
11
+ "reference": "node",
12
+ "referenceNote": "TypeScript is the SPECIFICATION here, not a peer. It ships no executors — a host supplies them — so its declarations are the contract a conforming executor must satisfy, and they are the only ones that cannot be checked against code. PHP, Python and Rust each ship executors and can always fall back to reading their own source. So a disagreement is not 'two runtimes differ'; it is an implementation disagreeing with the spec, and the implementation is the one with an executor to be wrong about.",
13
+ "implementations": [
14
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "NodeKindDefinition.outputShape / .emits" },
15
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "NodeKind::outputShapeFor() / ::emitsFor()" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "NodeKind.output_shape_for() / .emits_for()" },
17
+ { "language": "rust", "package": "fancy-flow", "symbol": "NodeKind::output_fields() / .emits" }
18
+ ]
19
+ },
20
+ "notes": [
21
+ "THIS SUITE EXISTS BECAUSE THE CONFORMANCE TABLES COVERED BEHAVIOUR AND NOTHING COVERED SURFACE. Four capabilities were found present in one runtime and absent in the others, each time where ABSENT reads as a legitimate answer: `graph.inputs` dropped on import, `sideEffects` declared by nothing, the conformance Python loader never published, and `outputShape` existing only in TypeScript. In every one the wrong reading is the reassuring one, so nothing reported the gap. A fixture asserting that every runtime exposes the same declaration fields would have caught all four.",
22
+ "ASSERT THE SHAPE OF EACH FIELD, NOT ITS PRESENCE. `outputShape: OutputField[] | ((config) => OutputField[])` in one runtime and `outputShape: array` in another passes a presence check and fails every real use. A presence-only parity fixture would have been the fifth instance of this bug, written by the people fixing the first four. Raised by the reference consumer.",
23
+ "COMPARE FIELD PATHS AS A SET. `for_each` inserts `count` before `items` in the Rust executor and the reverse elsewhere; the values are maps, so the order carries no meaning. A fixture that asserted order would report a divergence that is not one, and a fixture that cries wolf is one nobody reads.",
24
+ "NULL, EMPTY AND DYNAMIC ARE THREE ANSWERS, and every row that pins one of them exists because collapsing them is the defect. `null` = nobody declared. `[]` = declares that it emits no fields. `\"dynamic\"` = declared, config-dependent, unresolvable in this process. A consumer that treats `null` as `[]` refuses every valid reference on an undeclared kind; one that treats `\"dynamic\"` as `null` falls back to a fixed table to answer a config-dependent question, which is wrong by construction.",
25
+ "A PASS-THROUGH KIND MUST DECLARE NOTHING. `branch`, `switch_case`, `output`, `transform`, `merge`, `manual_trigger`, `webhook_trigger`, `human_approval` and `variable` emit what arrived, so their field shape is not knowable from the kind. The rows asserting they stay `null` are not filler: a partial static list on such a kind refuses every field it omits, and a false rejection is one an author cannot comply with.",
26
+ "`wait` IS THE ROW THAT PROVES A RELATION NEEDS A DESTINATION. It returns `{waited, duration, input}` — it NESTS its input under a key rather than merging it at the top level. `emits: \"input\"` there would make a reader accept `{{ in.<any inbound field> }}` at top level, which resolves to nothing at run time. So it declares a field list and NO relation. Read the executor and ask *merge or nest* before assigning a relation; under-claiming is free.",
27
+ "`schedule_trigger` IS THE COMPOSITION CASE and it is correct precisely where `wait` is not: it merges its inputs into the TOP level alongside its own `cron`/`timezone`, so it declares both a list and a relation. It was undeclared until the relation existed, because a partial list with nothing to say the inputs also merge is a false-rejection generator.",
28
+ "`agent` IS NOT IN THIS TABLE, and the reason is worth recording: TypeScript has no `agent` kind, PHP registers it through its own `Builtin::agentKind()` rather than the default registry, and Rust declares it without an executor. A parity row would compare a kind three runtimes disagree about the existence of. Its emissions are pinned by each runtime's own suite instead — and the PHP one was wrong for twenty minutes because it cited the first of AgentExecutor's TWO returns and missed `truncated`, which taught the rule: read EVERY return, not the top one."
29
+ ]
30
+ }