@particle-academy/fancy-conformance 0.15.0 → 0.17.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.15.0
1
+ 0.17.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.15.0",
3
+ "version": "0.17.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",
@@ -1390,7 +1390,10 @@
1390
1390
  "ok": true,
1391
1391
  "value": null
1392
1392
  },
1393
- "notes": "Deliberate. There is no count three languages would agree on for an object, and an object used as a collection is rare enough not to justify inventing one. Null here is the ordinary absent-path answer, not a special case."
1393
+ "notes": "Deliberate. There is no count three languages would agree on for an object, and an object used as a collection is rare enough not to justify inventing one. Null here is the ordinary absent-path answer, not a special case. PHP cannot represent this case; see its skip reason and 0906.",
1394
+ "skip": {
1395
+ "php": "Not representable in PHP. json_decode('{}', true) and json_decode('[]', true) produce the IDENTICAL value, and array_is_list() calls both a list -- so an EMPTY object cannot be distinguished from an empty array, and the `.length` an empty array legitimately has is returned. The same limit shared/value-equality/0210 records. 0906 pins the same rule with a NON-EMPTY object, which every language can express."
1396
+ }
1394
1397
  },
1395
1398
  {
1396
1399
  "id": "0905-length-is-not-host-reach",
@@ -1429,6 +1432,119 @@
1429
1432
  "value": null
1430
1433
  },
1431
1434
  "notes": "`.length` is a computed count, not an opening. Nothing else on the prototype chain resolves -- these expressions arrive from end users and from agents, over the wire."
1435
+ },
1436
+ {
1437
+ "id": "0906-non-empty-object-has-no-length",
1438
+ "title": "A NON-EMPTY object has no `.length` either.",
1439
+ "since": "0.16.0",
1440
+ "tags": [
1441
+ "expr",
1442
+ "length",
1443
+ "deliberate"
1444
+ ],
1445
+ "input": {
1446
+ "expression": "user.length",
1447
+ "context": {
1448
+ "in": {
1449
+ "content": "hello",
1450
+ "count": 3,
1451
+ "deal_id": "D-1",
1452
+ "transcript": null,
1453
+ "items": [
1454
+ 1,
1455
+ 2
1456
+ ],
1457
+ "empty": [],
1458
+ "obj": {},
1459
+ "flag": true,
1460
+ "zero": 0,
1461
+ "blank": ""
1462
+ },
1463
+ "user": {
1464
+ "name": "Ada"
1465
+ }
1466
+ }
1467
+ },
1468
+ "expected": {
1469
+ "ok": true,
1470
+ "value": null
1471
+ },
1472
+ "notes": "The companion to 0904, and the form every runtime can express. PHP cannot distinguish an EMPTY object from an empty array -- both decode to the same value and both read as a list -- so 0904 is skipped there. A non-empty object is unambiguous in all three, which keeps the rule pinned rather than merely skipped. Same shape as flow/workflow-props 0106/0109."
1473
+ },
1474
+ {
1475
+ "id": "1001-the-normalisation-an-agent-writes",
1476
+ "title": "The production expression that started this package: object literal, || fallback chains and a ternary, every operand legitimately absent.",
1477
+ "since": "0.17.0",
1478
+ "tags": [
1479
+ "expr",
1480
+ "field-report"
1481
+ ],
1482
+ "notes": "VERBATIM from a consumer's production Op, authored by a chat agent that reasonably assumed `{{ }}` was an expression language. Under the old dot-path-only resolver the WHOLE thing evaluated to null and the run reported success. They describe it as 'what an agent writes when it is trying to normalise two trigger shapes into one, which is the single most common thing they attempt' -- so this is the shape to keep working, not a toy. Every other row here isolates one rule; this one is the rules COMPOSED, which is how they actually arrive.",
1483
+ "input": {
1484
+ "expression": "{ transcript: in.transcript || in.file_content || '', deal_id: in.deal_id || '', file_name: in.name || 'Pasted transcript', source: in.transcript ? 'manual' : 'file_event' }",
1485
+ "context": {
1486
+ "in": {
1487
+ "transcript": null,
1488
+ "file_content": "…the call went well…",
1489
+ "deal_id": "D-1",
1490
+ "name": null
1491
+ }
1492
+ }
1493
+ },
1494
+ "expected": {
1495
+ "ok": true,
1496
+ "value": {
1497
+ "transcript": "…the call went well…",
1498
+ "deal_id": "D-1",
1499
+ "file_name": "Pasted transcript",
1500
+ "source": "file_event"
1501
+ }
1502
+ }
1503
+ },
1504
+ {
1505
+ "id": "1002-the-branch-condition-that-took-the-wrong-road",
1506
+ "title": "The other half of the same Op: a branch condition using && and !==.",
1507
+ "since": "0.17.0",
1508
+ "tags": [
1509
+ "expr",
1510
+ "field-report"
1511
+ ],
1512
+ "notes": "The expensive face of the defect. `truthy(null)` is false, so a condition the engine could not evaluate did not error -- it silently picked the false road on EVERY run while the run reported success. 0406 pins the same expression against a present deal_id; this pins it against an ABSENT one, where the honest answer is a real false rather than a false that means 'could not tell'.",
1513
+ "input": {
1514
+ "expression": "in.deal_id && in.deal_id !== ''",
1515
+ "context": {
1516
+ "in": {
1517
+ "deal_id": null
1518
+ }
1519
+ }
1520
+ },
1521
+ "expected": {
1522
+ "ok": true,
1523
+ "value": null
1524
+ }
1525
+ },
1526
+ {
1527
+ "id": "1003-empty-string-fallback-survives-the-chain",
1528
+ "title": "A || chain ending in '' yields the empty string, not null.",
1529
+ "since": "0.17.0",
1530
+ "tags": [
1531
+ "expr",
1532
+ "field-report"
1533
+ ],
1534
+ "notes": "Extracted from 1001 because the composed row would still pass if this rule broke -- its `transcript` key never reaches the final fallback. An author writes `|| ''` precisely so a missing field becomes a printable gap rather than the word null.",
1535
+ "input": {
1536
+ "expression": "in.a || in.b || ''",
1537
+ "context": {
1538
+ "in": {
1539
+ "a": null,
1540
+ "b": null
1541
+ }
1542
+ }
1543
+ },
1544
+ "expected": {
1545
+ "ok": true,
1546
+ "value": ""
1547
+ }
1432
1548
  }
1433
1549
  ]
1434
1550
  }
@@ -36,6 +36,7 @@
36
36
  "`&&` AND `||` RETURN THE OPERAND, NOT A BOOLEAN (0401-0404). That is what makes `in.transcript || in.content` a useful fallback rather than merely `true` — and the reported production case wrote exactly that shape.",
37
37
  "MALFORMED EXPRESSIONS FAIL (0801-0805) and never return null. A null there would be indistinguishable from an absent path, which is the defect being removed. 0805 pins that a function call does not parse: sandboxing is a security property here, not a style preference, because these expressions arrive from end users and from agents over the wire.",
38
38
  "This suite needs DISCRIMINATION PROBES before it can claim more than drift-guarding — deliberately-wrong evaluators that must each fail an exact set of ids. Notably: one that uses native truthiness (must fail 0301/0302), one that coerces on `==` (must fail 0503), and one that returns booleans from `&&`/`||` (must fail 0401/0403/0404). Not yet written; recorded so a green tick is not read as the stronger claim.",
39
- "`.length` (0901-0905) IS the one pseudo-property, and it is load-bearing rather than convenient. Because `[]` is TRUTHY, a consumer with no `.length` would have no way to ask whether a collection is EMPTY -- the grammar would assert that an array which exists is a value and then leave nobody able to test the thing they care about. It is a computed count, not host reach: nothing is called and no prototype is walked, and 0905 pins that nothing else on the chain resolves. Objects deliberately have none (0904), since no count would survive three languages. These rows were added after writing the reference implementation's discrimination tests found the SPEC contradicting the CODE -- the truthiness rule was justified with `.length` while `.length` returned null."
39
+ "`.length` (0901-0905) IS the one pseudo-property, and it is load-bearing rather than convenient. Because `[]` is TRUTHY, a consumer with no `.length` would have no way to ask whether a collection is EMPTY -- the grammar would assert that an array which exists is a value and then leave nobody able to test the thing they care about. It is a computed count, not host reach: nothing is called and no prototype is walked, and 0905 pins that nothing else on the chain resolves. Objects deliberately have none (0904), since no count would survive three languages. These rows were added after writing the reference implementation's discrimination tests found the SPEC contradicting the CODE -- the truthiness rule was justified with `.length` while `.length` returned null.",
40
+ "THE TABLE EARNED ITS KEEP ON THE FIRST INDEPENDENT IMPLEMENTATION, exactly as this manifest predicted it would. The PHP port -- written against these rows rather than against the TypeScript source -- failed 1 of 44 on its first run: 0904, that an object has no `.length`. It is not a bug in the port. json_decode('{}', true) and json_decode('[]', true) produce the IDENTICAL value in PHP and array_is_list() calls both a list, so an EMPTY object is indistinguishable from an empty array and the length the array legitimately has is returned. A genuine cross-language expressiveness limit, the same one shared/value-equality/0210 records. Skipped there with the reason attached, and 0906 pins the identical rule with a NON-EMPTY object that every runtime can express -- the same two-row shape as flow/workflow-props 0106/0109."
40
41
  ]
41
42
  }
@@ -0,0 +1,226 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "expr/references",
4
+ "cases": [
5
+ {
6
+ "id": "0101-single-root",
7
+ "title": "One path reads one root.",
8
+ "since": "0.17.0",
9
+ "tags": [
10
+ "expr",
11
+ "references"
12
+ ],
13
+ "input": {
14
+ "expression": "in.content"
15
+ },
16
+ "expected": {
17
+ "ok": true,
18
+ "value": [
19
+ "in"
20
+ ]
21
+ }
22
+ },
23
+ {
24
+ "id": "0102-unique-and-sorted",
25
+ "title": "Repeated roots collapse; the order is sorted, not source order.",
26
+ "since": "0.17.0",
27
+ "tags": [
28
+ "expr",
29
+ "references"
30
+ ],
31
+ "notes": "Sorted and unique so three languages produce the SAME list. A set iterated in insertion order agrees with a sorted list often enough to look correct and not always -- and the disagreement would surface as a host rejecting an expression in one runtime and accepting it in another.",
32
+ "input": {
33
+ "expression": "user.name + in.a + in.b + user.id"
34
+ },
35
+ "expected": {
36
+ "ok": true,
37
+ "value": [
38
+ "in",
39
+ "user"
40
+ ]
41
+ }
42
+ },
43
+ {
44
+ "id": "0103-dollar-root-is-reported-like-any-other",
45
+ "title": "A $-prefixed root is returned, not filtered and not special-cased.",
46
+ "since": "0.17.0",
47
+ "tags": [
48
+ "expr",
49
+ "references"
50
+ ],
51
+ "notes": "THE REPORTED CASE. This package does not know whether `$now` exists -- `$json`, `$input` and `$props` do exist in one host and mean nothing in another. It reports what is read; the HOST owns the list. Filtering `$` names here would silently delete the only signal the check needs.",
52
+ "input": {
53
+ "expression": "$now"
54
+ },
55
+ "expected": {
56
+ "ok": true,
57
+ "value": [
58
+ "$now"
59
+ ]
60
+ }
61
+ },
62
+ {
63
+ "id": "0104-mixed-dollar-and-plain",
64
+ "title": "Both kinds in one expression.",
65
+ "since": "0.17.0",
66
+ "tags": [
67
+ "expr",
68
+ "references"
69
+ ],
70
+ "input": {
71
+ "expression": "$props.content || in.content"
72
+ },
73
+ "expected": {
74
+ "ok": true,
75
+ "value": [
76
+ "$props",
77
+ "in"
78
+ ]
79
+ }
80
+ },
81
+ {
82
+ "id": "0105-computed-index-is-a-reference",
83
+ "title": "A computed index reads a root too.",
84
+ "since": "0.17.0",
85
+ "tags": [
86
+ "expr",
87
+ "references"
88
+ ],
89
+ "notes": "An implementation that walked only `.name` steps passes every other row in this suite and fails exactly here. A typo'd index gets the same silent empty as `$now`, so it needs the same static answer.",
90
+ "input": {
91
+ "expression": "items[i]"
92
+ },
93
+ "expected": {
94
+ "ok": true,
95
+ "value": [
96
+ "i",
97
+ "items"
98
+ ]
99
+ }
100
+ },
101
+ {
102
+ "id": "0106-object-keys-are-not-references",
103
+ "title": "An object literal's KEYS are written, not read.",
104
+ "since": "0.17.0",
105
+ "tags": [
106
+ "expr",
107
+ "references"
108
+ ],
109
+ "notes": "The damaging direction: an implementation collecting every identifier would report `transcript` and `source`, and the host would reject a perfectly valid expression. A false rejection at save time is worse than a missed one, because the author has no way to comply.",
110
+ "input": {
111
+ "expression": "{ transcript: in.content, source: 'manual' }"
112
+ },
113
+ "expected": {
114
+ "ok": true,
115
+ "value": [
116
+ "in"
117
+ ]
118
+ }
119
+ },
120
+ {
121
+ "id": "0107-literals-read-nothing",
122
+ "title": "An expression with no paths reads nothing.",
123
+ "since": "0.17.0",
124
+ "tags": [
125
+ "expr",
126
+ "references"
127
+ ],
128
+ "input": {
129
+ "expression": "1 + 2 === 3 ? 'yes' : 'no'"
130
+ },
131
+ "expected": {
132
+ "ok": true,
133
+ "value": []
134
+ }
135
+ },
136
+ {
137
+ "id": "0108-both-sides-of-a-branch",
138
+ "title": "Every branch is read, not only the taken one.",
139
+ "since": "0.17.0",
140
+ "tags": [
141
+ "expr",
142
+ "references"
143
+ ],
144
+ "notes": "A static question has no run to take a branch in. An implementation that short-circuited here -- reusing the evaluator's logic -- would report only what one run happened to touch, and the host would approve an expression that fails on the other road.",
145
+ "input": {
146
+ "expression": "a ? b : c"
147
+ },
148
+ "expected": {
149
+ "ok": true,
150
+ "value": [
151
+ "a",
152
+ "b",
153
+ "c"
154
+ ]
155
+ }
156
+ },
157
+ {
158
+ "id": "0109-the-field-report-normalisation",
159
+ "title": "The consumer's production expression reads exactly one root.",
160
+ "since": "0.17.0",
161
+ "tags": [
162
+ "expr",
163
+ "references"
164
+ ],
165
+ "notes": "The same expression as expr/evaluate 1001, asked the static question. Four keys, three fallback chains and a ternary, and the honest answer is one root -- which is what makes the check cheap enough for a host to run on every save.",
166
+ "input": {
167
+ "expression": "{ transcript: in.transcript || in.file_content || '', deal_id: in.deal_id || '', file_name: in.name || 'Pasted transcript', source: in.transcript ? 'manual' : 'file_event' }"
168
+ },
169
+ "expected": {
170
+ "ok": true,
171
+ "value": [
172
+ "in"
173
+ ]
174
+ }
175
+ },
176
+ {
177
+ "id": "0110-length-is-not-a-root",
178
+ "title": "`.length` is a step, never a name to supply.",
179
+ "since": "0.17.0",
180
+ "tags": [
181
+ "expr",
182
+ "references"
183
+ ],
184
+ "input": {
185
+ "expression": "results.length === 0"
186
+ },
187
+ "expected": {
188
+ "ok": true,
189
+ "value": [
190
+ "results"
191
+ ]
192
+ }
193
+ },
194
+ {
195
+ "id": "0201-malformed-does-not-parse",
196
+ "title": "A malformed expression fails here exactly as it fails in parse().",
197
+ "since": "0.17.0",
198
+ "tags": [
199
+ "expr",
200
+ "references"
201
+ ],
202
+ "notes": "Same outcome, same reason: this is a question about a syntax tree, and there is no tree. Returning an empty list instead would tell a host 'this expression needs nothing', and it would save a node that can never run.",
203
+ "input": {
204
+ "expression": "in.a &&"
205
+ },
206
+ "expected": {
207
+ "ok": false
208
+ }
209
+ },
210
+ {
211
+ "id": "0202-a-call-does-not-parse",
212
+ "title": "A call is still refused at parse time.",
213
+ "since": "0.17.0",
214
+ "tags": [
215
+ "expr",
216
+ "references"
217
+ ],
218
+ "input": {
219
+ "expression": "count(in.items)"
220
+ },
221
+ "expected": {
222
+ "ok": false
223
+ }
224
+ }
225
+ ]
226
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "expr/references",
4
+ "title": "What an expression READS -- the root names a host must be able to supply",
5
+ "since": "0.17.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "references(expression: string) -> { ok: true, value: string[] } | { ok: false }",
10
+ "summary": "Return the ROOT identifiers an expression reads, unique and sorted, without evaluating anything and without a context. `{ ok: false }` when the expression cannot PARSE -- the same failure `parse()` reports, because this is a static question asked of the same syntax tree. Object-literal KEYS are not references; a computed index IS one.",
11
+ "reference": "node",
12
+ "implementations": [
13
+ { "language": "node", "package": "@particle-academy/fancy-expr", "symbol": "references" },
14
+ { "language": "php", "package": "particle-academy/fancy-expr", "symbol": "FancyExpr\\Expr::references" },
15
+ { "language": "python", "package": "fancy-expr", "symbol": "fancy_expr.references" }
16
+ ]
17
+ },
18
+ "notes": [
19
+ "WHY THIS EXISTS, and it is not a convenience. A consumer reported that `{{ $now }}` renders as nothing: `$`-prefixed roots read to an author as ENGINE-PROVIDED, so agents reach for `$now` / `$today` / `$index` the way they reach for the two that exist. A real document shipped titled 'Deal List Export -' with the date silently missing. Their words: an unknown `$` root is detectable at PARSE time in a way `in.genuinely_absent` is not.",
20
+ "THE HOST DECIDES, NOT THIS PACKAGE. `fancy-expr` must never carry a list of magic root names -- it does not know whether `$props` exists, and inventing an allowlist would make it wrong for every host but one. So it answers the only question it CAN answer honestly ('what does this expression read?') and the host compares that against what it actually provides. That keeps the grammar host-agnostic while making the check possible at all.",
21
+ "IT ALSO CATCHES THE SECOND REPORTED SHAPE. The same consumer reported `{{ n2.transcript }}` -- a REAL node id, two hops upstream, which is legal-looking and resolves to nothing because a node id only addresses a direct predecessor. A host holding the predecessor set can refuse that from these roots too. One primitive, both shapes, and neither rule living in this package.",
22
+ "WHAT IT DELIBERATELY CANNOT CATCH: `{{ in.output }}` -- a real port and a field that node never emits. The root is legitimate, so nothing static can tell an absent field from a field that is absent THIS RUN. That is the boundary of the idea and is recorded so nobody reads a green check as covering it.",
23
+ "COMPUTED INDICES COUNT (0105). `items[i]` reads `i`, and an author who typo'd it gets the same silent empty as `$now`. An implementation that walked only `.name` steps would pass every other row here.",
24
+ "OBJECT KEYS DO NOT COUNT (0106). `{ transcript: in.content }` reads `in` and nothing else -- `transcript` is a key being written, not a name being read. An implementation collecting every identifier it sees would report both and make the host reject a valid expression, which is the more damaging direction of wrong."
25
+ ]
26
+ }