@particle-academy/fancy-conformance 0.0.0 → 0.2.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.
@@ -0,0 +1,75 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://ui.particle.academy/schemas/conformance-suite-manifest.schema.json",
4
+ "title": "Fancy conformance suite manifest",
5
+ "type": "object",
6
+ "required": ["suite", "title", "since", "caseFormat", "contract"],
7
+ "properties": {
8
+ "$schema": { "type": "string" },
9
+ "suite": {
10
+ "description": "Path-shaped id, matching this file's directory under suites/.",
11
+ "type": "string",
12
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*$"
13
+ },
14
+ "title": { "type": "string", "minLength": 1 },
15
+ "since": {
16
+ "description": "Suite version in which this suite first appeared.",
17
+ "type": "string",
18
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
19
+ },
20
+ "caseFormat": {
21
+ "description": "'table' — one cases.json whose rows are the cases; for pure functions. 'directory' — cases/<id>/ holding input + expected artifacts; for capabilities that emit files.",
22
+ "enum": ["table", "directory"]
23
+ },
24
+ "cases": {
25
+ "description": "For caseFormat=table, the file holding the rows. Defaults to cases.json.",
26
+ "type": "string"
27
+ },
28
+ "contract": {
29
+ "type": "object",
30
+ "required": ["summary", "implementations"],
31
+ "properties": {
32
+ "summary": { "type": "string", "minLength": 1 },
33
+ "function": { "type": "string" },
34
+ "functions": {
35
+ "type": "object",
36
+ "additionalProperties": { "type": "string" }
37
+ },
38
+ "runShape": { "type": "object" },
39
+ "reference": {
40
+ "description": "Which language's behaviour the goldens were taken from. Goldens are never what a value 'obviously' is.",
41
+ "enum": ["php", "node", "rust", "python", "go"]
42
+ },
43
+ "referenceNote": { "type": "string" },
44
+ "implementations": {
45
+ "type": "array",
46
+ "minItems": 1,
47
+ "items": {
48
+ "type": "object",
49
+ "required": ["language", "package"],
50
+ "properties": {
51
+ "language": { "enum": ["php", "node", "rust", "python", "go"] },
52
+ "package": { "type": "string", "minLength": 1 },
53
+ "symbol": { "type": "string" }
54
+ },
55
+ "additionalProperties": false
56
+ }
57
+ }
58
+ },
59
+ "additionalProperties": false
60
+ },
61
+ "normalisation": {
62
+ "description": "For artifact suites only: which parts of an emitted document are ignored when comparing. Declared once per suite so a comparison never quietly loosens per case.",
63
+ "type": "object",
64
+ "properties": {
65
+ "ignoreAttributes": { "type": "array", "items": { "type": "string" } },
66
+ "orderInsensitive": { "type": "array", "items": { "type": "string" } },
67
+ "whitespace": { "enum": ["preserve", "collapse-between-elements"] },
68
+ "newline": { "enum": ["lf", "crlf", "preserve"] }
69
+ },
70
+ "additionalProperties": false
71
+ },
72
+ "notes": { "type": "array", "items": { "type": "string" } }
73
+ },
74
+ "additionalProperties": false
75
+ }
@@ -0,0 +1,182 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/decimal",
4
+ "cases": [
5
+ {
6
+ "id": "0001-format-plain",
7
+ "title": "formatFloat leaves ordinary numbers alone",
8
+ "since": "0.1.0",
9
+ "tags": ["formatFloat", "regression"],
10
+ "fn": "formatFloat",
11
+ "input": { "value": 1.5 },
12
+ "expected": "1.5"
13
+ },
14
+ {
15
+ "id": "0002-format-integral",
16
+ "title": "formatFloat keeps trailing zeros in the integer part",
17
+ "since": "0.1.0",
18
+ "tags": ["formatFloat"],
19
+ "fn": "formatFloat",
20
+ "input": { "value": 1200 },
21
+ "expected": "1200",
22
+ "notes": "Only the FRACTION is trimmed. A blanket trailing-zero strip turns 1200 into 12."
23
+ },
24
+ {
25
+ "id": "0003-format-1e300",
26
+ "title": "formatFloat expands 1e300 exactly, never exponentially",
27
+ "since": "0.1.0",
28
+ "tags": ["formatFloat", "exponent", "hazard"],
29
+ "fn": "formatFloat",
30
+ "input": { "value": 1e300 },
31
+ "expected": "1000000000000000052504760255204420248704468581108159154915854115511802457988908195786371375080447864043704443832883878176942523235360430575644792184786706982848387200926575803737830233794788090059368953234970799945081119038967640880074652742780142494579258788820056842838115669472196386865459400540160",
32
+ "notes": "The severe one. toFixed goes exponential at 1e21, and a trailing-zero strip then eats the EXPONENT: (1e300).toFixed(14) is '1e+300', which strips to '1e+3'. 1e300 was written into spreadsheets as 1e3 — not a crash, not invalid XML, just a different number in a file someone opens later and believes. NOTE the golden is NOT 1 followed by 300 zeros: number_format prints the exact value of the double, and the nearest double to 1e300 is not 10^300."
33
+ },
34
+ {
35
+ "id": "0004-format-1e21-boundary",
36
+ "title": "formatFloat does not go exponential at the 1e21 boundary",
37
+ "since": "0.1.0",
38
+ "tags": ["formatFloat", "exponent", "hazard"],
39
+ "fn": "formatFloat",
40
+ "input": { "value": 1e21 },
41
+ "expected": "1000000000000000000000",
42
+ "notes": "1e21 is exactly where JS toFixed switches notation. `<v>1e+21</v>` is not valid cell content, so a reader may reject the whole sheet."
43
+ },
44
+ {
45
+ "id": "0005-format-negative-zero",
46
+ "title": "formatFloat collapses negative zero to 0",
47
+ "since": "0.1.0",
48
+ "tags": ["formatFloat", "edge"],
49
+ "fn": "formatFloat",
50
+ "input": { "value": -0.0 },
51
+ "expected": "0",
52
+ "notes": "Both engines already agreed here — this was listed as a divergence in the polyglot plan and checking proved it was not one. Kept because both sides drop the sign INCIDENTALLY (via number_format / toFixed) rather than by decision, so a faster hand-rolled formatter would reintroduce it."
53
+ },
54
+ {
55
+ "id": "0006-format-repeating",
56
+ "title": "formatFloat keeps 14 decimal places",
57
+ "since": "0.1.0",
58
+ "tags": ["formatFloat", "precision"],
59
+ "fn": "formatFloat",
60
+ "input": { "value": 0.3333333333333333 },
61
+ "expected": "0.33333333333333",
62
+ "notes": "14, not 15 or 17. The step where two implementations pick different precision is invisible until someone diffs two documents."
63
+ },
64
+ {
65
+ "id": "0007-format-negative",
66
+ "title": "formatFloat keeps the sign on a real negative",
67
+ "since": "0.1.0",
68
+ "tags": ["formatFloat", "regression"],
69
+ "fn": "formatFloat",
70
+ "input": { "value": -2.25 },
71
+ "expected": "-2.25"
72
+ },
73
+ {
74
+ "id": "0008-coerce-exponent",
75
+ "title": "numericStringToNumber reads exponent notation",
76
+ "since": "0.1.0",
77
+ "tags": ["numericStringToNumber", "exponent", "hazard"],
78
+ "fn": "numericStringToNumber",
79
+ "input": { "value": "1e5" },
80
+ "expected": 100000,
81
+ "notes": "parseInt stops at the first non-digit, so '1e5' became 1. PHP has read exponent notation in numeric strings since PHP 7, and the JS helper's own docstring claimed it mirrored PHP."
82
+ },
83
+ {
84
+ "id": "0009-coerce-negative-exponent",
85
+ "title": "numericStringToNumber reads a negative exponent",
86
+ "since": "0.1.0",
87
+ "tags": ["numericStringToNumber", "exponent"],
88
+ "fn": "numericStringToNumber",
89
+ "input": { "value": "2e-3" },
90
+ "expected": 0.002,
91
+ "notes": "PHP's dot-test-then-(int) path returned 0 for this — a value silently becoming zero is the worst failure mode in the table."
92
+ },
93
+ {
94
+ "id": "0010-coerce-int-overflow",
95
+ "title": "numericStringToNumber does not clamp at the platform integer max",
96
+ "since": "0.1.0",
97
+ "tags": ["numericStringToNumber", "overflow", "hazard"],
98
+ "fn": "numericStringToNumber",
99
+ "input": { "value": "1e21" },
100
+ "expected": 1e21,
101
+ "notes": "PHP's (int) '1e21' clamps to PHP_INT_MAX (9223372036854775807), which is not the number anyone wrote. Rust and Go have the same trap with i64."
102
+ },
103
+ {
104
+ "id": "0011-coerce-leading-zeros",
105
+ "title": "numericStringToNumber ignores leading zeros",
106
+ "since": "0.1.0",
107
+ "tags": ["numericStringToNumber", "regression"],
108
+ "fn": "numericStringToNumber",
109
+ "input": { "value": "007" },
110
+ "expected": 7,
111
+ "notes": "Must NOT be read as octal — a trap in several languages' parse routines."
112
+ },
113
+ {
114
+ "id": "0012-coerce-leading-dot",
115
+ "title": "numericStringToNumber accepts a leading dot",
116
+ "since": "0.1.0",
117
+ "tags": ["numericStringToNumber", "regression"],
118
+ "fn": "numericStringToNumber",
119
+ "input": { "value": ".5" },
120
+ "expected": 0.5
121
+ },
122
+ {
123
+ "id": "0013-round-positive-half",
124
+ "title": "roundMoney rounds a positive half away from zero",
125
+ "since": "0.1.0",
126
+ "tags": ["roundMoney", "money"],
127
+ "fn": "roundMoney",
128
+ "input": { "value": 2.5 },
129
+ "expected": 3,
130
+ "notes": "PHP round() and JS Math.round() agree here, which is exactly why the divergence below went unnoticed."
131
+ },
132
+ {
133
+ "id": "0014-round-negative-half",
134
+ "title": "roundMoney rounds a negative half away from zero",
135
+ "since": "0.1.0",
136
+ "tags": ["roundMoney", "money", "hazard", "live-divergence"],
137
+ "fn": "roundMoney",
138
+ "input": { "value": -2.5 },
139
+ "expected": -3,
140
+ "notes": "LIVE DIVERGENCE at the time this suite was written. PHP round(-2.5) is -3 (half away from zero); JS Math.round(-2.5) is -2 (half toward +Infinity). fancy-mlm-js's amountAsInt is documented as a mirror of fancy-mlm-php's and is not one. Reachable through configuration: levelFactors is `.map(Number)` off host config with no sign validation, so a negative factor produces a negative reward, and the two backends then pay different amounts for the same event."
141
+ },
142
+ {
143
+ "id": "0015-round-negative-half-small",
144
+ "title": "roundMoney rounds -0.5 to -1, not to zero",
145
+ "since": "0.1.0",
146
+ "tags": ["roundMoney", "money", "hazard", "live-divergence"],
147
+ "fn": "roundMoney",
148
+ "input": { "value": -0.5 },
149
+ "expected": -1,
150
+ "notes": "The starkest row: JS Math.round(-0.5) is -0, so a clawback of half a unit becomes NOTHING on one backend and a whole unit on the other."
151
+ },
152
+ {
153
+ "id": "0016-round-symmetry",
154
+ "title": "roundMoney is symmetric about zero",
155
+ "since": "0.1.0",
156
+ "tags": ["roundMoney", "money"],
157
+ "fn": "roundMoney",
158
+ "input": { "value": -1.5 },
159
+ "expected": -2,
160
+ "notes": "The property that matters for money: |round(-x)| == |round(x)|. A credit and its matching clawback must cancel exactly, or a ledger drifts by a unit per reversal."
161
+ },
162
+ {
163
+ "id": "0017-round-below-half",
164
+ "title": "roundMoney rounds below a half toward zero",
165
+ "since": "0.1.0",
166
+ "tags": ["roundMoney", "regression"],
167
+ "fn": "roundMoney",
168
+ "input": { "value": -2.4 },
169
+ "expected": -2,
170
+ "notes": "Both engines already agree. Present so an implementation switching to a blanket floor/ceil fails rather than passing the halves by luck."
171
+ },
172
+ {
173
+ "id": "0018-round-above-half",
174
+ "title": "roundMoney rounds above a half away from zero",
175
+ "since": "0.1.0",
176
+ "tags": ["roundMoney", "regression"],
177
+ "fn": "roundMoney",
178
+ "input": { "value": -2.6 },
179
+ "expected": -3
180
+ }
181
+ ]
182
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/decimal",
4
+ "title": "Number formatting, numeric-string coercion, and money rounding",
5
+ "since": "0.1.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "summary": "Three separate functions that every backend needs and that every backend has so far gotten subtly differently.",
10
+ "functions": {
11
+ "formatFloat": "formatFloat(v: float) -> string — a float as it is written into a document, plain decimal, never exponential",
12
+ "numericStringToNumber": "numericStringToNumber(s: string) -> number — coercion of a numeric STRING supplied by a host",
13
+ "roundMoney": "roundMoney(v: float) -> int — the rounding applied to a currency/points amount before it is paid out"
14
+ },
15
+ "reference": "php",
16
+ "referenceNote": "PHP is the reference for all three because the pair's contract is 'same result either backend' and PHP is what shipped first. formatFloat is `rtrim(rtrim(number_format((float) $v, 14, '.', ''), '0'), '.')`; roundMoney is `(int) round($v)`.",
17
+ "implementations": [
18
+ { "language": "php", "package": "particle-academy/holy-sheet", "symbol": "formatFloat, numericStringToNumber" },
19
+ { "language": "node", "package": "@particle-academy/holy-sheet-js", "symbol": "formatFloat, numericStringToNumber" },
20
+ { "language": "php", "package": "particle-academy/fancy-mlm-php", "symbol": "RewardComputation::amountAsInt" },
21
+ { "language": "node", "package": "@particle-academy/fancy-mlm-js", "symbol": "amountAsInt" }
22
+ ]
23
+ },
24
+ "notes": [
25
+ "Every formatFloat/numericStringToNumber case here was a live PHP-vs-JS disagreement in shipped packages, fixed in holy-sheet 2.0.1 / holy-sheet-js 2.1.0. They are pinned so a third language inherits the behaviour rather than the assumption.",
26
+ "The roundMoney cases are NOT historical. They were found by writing this suite on 2026-08-10 and are a live divergence at the time of writing: PHP round() is half-away-from-zero, JS Math.round() is half-toward-positive-infinity, and they disagree on every negative half.",
27
+ "Goldens are not what the value 'obviously' is. number_format prints the exact value of the double, and the nearest double to 1e300 is not 10^300 — see case 0003."
28
+ ]
29
+ }
@@ -0,0 +1,186 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/expr",
4
+ "cases": [
5
+ {
6
+ "id": "0001-whole-string-keeps-type",
7
+ "title": "A whole-string expression returns the resolved VALUE, not its text",
8
+ "since": "0.2.0",
9
+ "tags": ["evaluate", "types"],
10
+ "fn": "evaluateExpression",
11
+ "input": { "template": "{{ $json.count }}", "context": { "in": { "count": 3 } } },
12
+ "expected": 3
13
+ },
14
+ {
15
+ "id": "0002-mixed-text-interpolates",
16
+ "title": "Anything other than a whole-string expression interpolates as text",
17
+ "since": "0.2.0",
18
+ "tags": ["evaluate"],
19
+ "fn": "evaluateExpression",
20
+ "input": { "template": "Hi {{ $json.name }}, you have {{ $json.count }}", "context": { "in": { "name": "Ada", "count": 3 } } },
21
+ "expected": "Hi Ada, you have 3"
22
+ },
23
+ {
24
+ "id": "0003-input-alias",
25
+ "title": "$input is an alias for $json",
26
+ "since": "0.2.0",
27
+ "tags": ["evaluate", "alias"],
28
+ "fn": "evaluateExpression",
29
+ "input": { "template": "{{ $input.name }}", "context": { "in": { "name": "Ada" } } },
30
+ "expected": "Ada"
31
+ },
32
+ {
33
+ "id": "0004-alias-falls-back-to-whole-context",
34
+ "title": "With no `in` key, $json addresses the whole context",
35
+ "since": "0.2.0",
36
+ "tags": ["evaluate", "alias"],
37
+ "fn": "evaluateExpression",
38
+ "input": { "template": "{{ $json.answer }}", "context": { "answer": "42" } },
39
+ "expected": "42"
40
+ },
41
+ {
42
+ "id": "0005-bare-top-level-key",
43
+ "title": "A bare path addresses the context, not the input",
44
+ "since": "0.2.0",
45
+ "tags": ["evaluate"],
46
+ "fn": "evaluateExpression",
47
+ "input": { "template": "{{ answer }}", "context": { "answer": "yes", "in": { "answer": "no" } } },
48
+ "expected": "yes"
49
+ },
50
+ {
51
+ "id": "0006-nested-dot-path",
52
+ "title": "Dot-paths nest",
53
+ "since": "0.2.0",
54
+ "tags": ["evaluate"],
55
+ "fn": "evaluateExpression",
56
+ "input": { "template": "{{ $json.user.email }}", "context": { "in": { "user": { "email": "a@b.c" } } } },
57
+ "expected": "a@b.c"
58
+ },
59
+ {
60
+ "id": "0007-missing-path-is-null",
61
+ "title": "An unresolvable path is null, not an error and not the literal text",
62
+ "since": "0.2.0",
63
+ "tags": ["evaluate", "regression"],
64
+ "fn": "evaluateExpression",
65
+ "input": { "template": "{{ $json.nope }}", "context": { "in": { "yes": 1 } } },
66
+ "expected": null
67
+ },
68
+ {
69
+ "id": "0008-missing-path-interpolates-empty",
70
+ "title": "A missing path interpolates to empty, never to \"null\"",
71
+ "since": "0.2.0",
72
+ "tags": ["evaluate", "regression"],
73
+ "fn": "evaluateExpression",
74
+ "input": { "template": "[{{ $json.nope }}]", "context": { "in": {} } },
75
+ "expected": "[]"
76
+ },
77
+ {
78
+ "id": "0009-non-string-passes-through",
79
+ "title": "A non-string template is returned untouched",
80
+ "since": "0.2.0",
81
+ "tags": ["evaluate"],
82
+ "fn": "evaluateExpression",
83
+ "input": { "template": 7, "context": { "in": {} } },
84
+ "expected": 7
85
+ },
86
+ {
87
+ "id": "0010-plain-string-untouched",
88
+ "title": "A string with no braces is returned as-is",
89
+ "since": "0.2.0",
90
+ "tags": ["evaluate"],
91
+ "fn": "evaluateExpression",
92
+ "input": { "template": "just text", "context": { "in": {} } },
93
+ "expected": "just text"
94
+ },
95
+ {
96
+ "id": "0011-object-interpolates-as-json",
97
+ "title": "Interpolating an object stringifies it as JSON",
98
+ "since": "0.2.0",
99
+ "tags": ["evaluate", "stringify"],
100
+ "fn": "evaluateExpression",
101
+ "input": { "template": "payload: {{ $json.body }}", "context": { "in": { "body": { "a": 1 } } } },
102
+ "expected": "payload: {\"a\":1}"
103
+ },
104
+ {
105
+ "id": "0012-boolean-interpolates-lowercase",
106
+ "title": "Booleans interpolate as true/false, not 1/empty",
107
+ "since": "0.2.0",
108
+ "tags": ["evaluate", "stringify", "regression"],
109
+ "fn": "evaluateExpression",
110
+ "input": { "template": "ok={{ $json.ok }}", "context": { "in": { "ok": false } } },
111
+ "expected": "ok=false"
112
+ },
113
+ {
114
+ "id": "0013-truthy-string-false",
115
+ "title": "The string \"false\" is FALSY",
116
+ "since": "0.2.0",
117
+ "tags": ["truthy", "regression"],
118
+ "fn": "truthy",
119
+ "input": { "value": "false" },
120
+ "expected": false
121
+ },
122
+ {
123
+ "id": "0014-truthy-string-zero",
124
+ "title": "The string \"0\" is FALSY",
125
+ "since": "0.2.0",
126
+ "tags": ["truthy", "regression"],
127
+ "fn": "truthy",
128
+ "input": { "value": "0" },
129
+ "expected": false
130
+ },
131
+ {
132
+ "id": "0015-truthy-empty-array",
133
+ "title": "An empty array is FALSY",
134
+ "since": "0.2.0",
135
+ "tags": ["truthy", "regression"],
136
+ "fn": "truthy",
137
+ "input": { "value": [] },
138
+ "expected": false
139
+ },
140
+ {
141
+ "id": "0016-truthy-nonempty-array",
142
+ "title": "A non-empty array is truthy",
143
+ "since": "0.2.0",
144
+ "tags": ["truthy"],
145
+ "fn": "truthy",
146
+ "input": { "value": [0] },
147
+ "expected": true
148
+ },
149
+ {
150
+ "id": "0017-truthy-zero",
151
+ "title": "Zero is falsy",
152
+ "since": "0.2.0",
153
+ "tags": ["truthy"],
154
+ "fn": "truthy",
155
+ "input": { "value": 0 },
156
+ "expected": false
157
+ },
158
+ {
159
+ "id": "0018-truthy-null",
160
+ "title": "null is falsy",
161
+ "since": "0.2.0",
162
+ "tags": ["truthy"],
163
+ "fn": "truthy",
164
+ "input": { "value": null },
165
+ "expected": false
166
+ },
167
+ {
168
+ "id": "0019-truthy-off",
169
+ "title": "\"off\", \"no\" and \"null\" are falsy strings too",
170
+ "since": "0.2.0",
171
+ "tags": ["truthy"],
172
+ "fn": "truthy",
173
+ "input": { "value": "OFF" },
174
+ "expected": false
175
+ },
176
+ {
177
+ "id": "0020-truthy-ordinary-string",
178
+ "title": "Any other non-empty string is truthy",
179
+ "since": "0.2.0",
180
+ "tags": ["truthy"],
181
+ "fn": "truthy",
182
+ "input": { "value": "no thanks" },
183
+ "expected": true
184
+ }
185
+ ]
186
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/expr",
4
+ "title": "{{ }} expression resolution for fancy-flow node config",
5
+ "since": "0.2.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "evaluateExpression(template: unknown, context: object): unknown | truthy(value: unknown): boolean",
10
+ "summary": "Resolve a dot-path template against an executor's inputs, and decide branch truthiness. Deliberately NOT a general expression language: dot-paths only, no arithmetic, comparisons or calls.",
11
+ "implementations": [
12
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Nodes\\Support\\Expr" },
13
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "evaluateExpression / truthy" }
14
+ ]
15
+ },
16
+ "notes": [
17
+ "This suite exists because of what conformance could NOT catch. `Expr` shipped in PHP with no TypeScript twin at all, and a fixture table compares two implementations — it is structurally unable to report a MISSING one. The gap surfaced only when someone asked for editor autocomplete over a grammar and the grammar turned out to live on one side.",
18
+ "`truthy` is where the two languages disagree by default, so those rows carry the weight. `\"0\"`, `\"false\"` and `[]` are all truthy in JavaScript and falsy here; a branch node reading a form value or a JSON body hits every one of them. An implementation that forwards to native truthiness fails cases 0013-0015 and nothing else, which is exactly the signal wanted.",
19
+ "Case 0008 pins that a missing path interpolates to an empty string rather than the text \"null\" — the difference between a quietly-blank message and one that ships the word null to a customer.",
20
+ "fancy-flow's own `runFlow` does not call this. The PHP runtime resolves expressions inside its executors; the JS runtime hands config to the host's executor verbatim, and every host that uses `{{ }}` today already resolves it itself. Turning it on inside `runFlow` would interpolate a second time over values those hosts had already substituted. The TS side is therefore an opt-in export, and this suite pins its SEMANTICS, not where it is called from."
21
+ ]
22
+ }
@@ -0,0 +1,147 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/satisfies-range",
4
+ "cases": [
5
+ {
6
+ "id": "0001-caret-pre-1.0-patch",
7
+ "title": "^0.15 accepts a patch bump",
8
+ "since": "0.1.0",
9
+ "tags": ["caret", "pre-1.0"],
10
+ "input": { "version": "0.15.1", "range": "^0.15" },
11
+ "expected": true
12
+ },
13
+ {
14
+ "id": "0002-caret-pre-1.0-minor",
15
+ "title": "^0.15 rejects a minor bump",
16
+ "since": "0.1.0",
17
+ "tags": ["caret", "pre-1.0"],
18
+ "input": { "version": "0.16.0", "range": "^0.15" },
19
+ "expected": false,
20
+ "notes": "Below 1.0.0 a minor bump is breaking, so ^0.15 means 0.15.x. This is the range every pre-1.0 package in the suite actually needs."
21
+ },
22
+ {
23
+ "id": "0003-caret-post-1.0-minor",
24
+ "title": "^1.0 accepts a minor bump",
25
+ "since": "0.1.0",
26
+ "tags": ["caret"],
27
+ "input": { "version": "1.2.0", "range": "^1.0" },
28
+ "expected": true
29
+ },
30
+ {
31
+ "id": "0004-caret-post-1.0-major",
32
+ "title": "^1.0 rejects a major bump",
33
+ "since": "0.1.0",
34
+ "tags": ["caret"],
35
+ "input": { "version": "2.0.0", "range": "^1.0" },
36
+ "expected": false
37
+ },
38
+ {
39
+ "id": "0005-gte-equal",
40
+ "title": ">=0.7 accepts the boundary itself",
41
+ "since": "0.1.0",
42
+ "tags": ["comparator"],
43
+ "input": { "version": "0.7.0", "range": ">=0.7" },
44
+ "expected": true
45
+ },
46
+ {
47
+ "id": "0006-gte-below",
48
+ "title": ">=0.7 rejects a lower version",
49
+ "since": "0.1.0",
50
+ "tags": ["comparator"],
51
+ "input": { "version": "0.5.0", "range": ">=0.7" },
52
+ "expected": false
53
+ },
54
+ {
55
+ "id": "0007-tilde-patch",
56
+ "title": "~0.7.1 accepts a higher patch",
57
+ "since": "0.1.0",
58
+ "tags": ["tilde"],
59
+ "input": { "version": "0.7.3", "range": "~0.7.1" },
60
+ "expected": true
61
+ },
62
+ {
63
+ "id": "0008-tilde-minor",
64
+ "title": "~0.7.1 rejects a minor bump",
65
+ "since": "0.1.0",
66
+ "tags": ["tilde"],
67
+ "input": { "version": "0.8.0", "range": "~0.7.1" },
68
+ "expected": false
69
+ },
70
+ {
71
+ "id": "0009-wildcard",
72
+ "title": "* accepts anything",
73
+ "since": "0.1.0",
74
+ "tags": ["wildcard"],
75
+ "input": { "version": "9.9.9", "range": "*" },
76
+ "expected": true
77
+ },
78
+ {
79
+ "id": "0010-union",
80
+ "title": "A || union accepts either side",
81
+ "since": "0.1.0",
82
+ "tags": ["union"],
83
+ "input": { "version": "0.7.0", "range": "^0.5 || ^0.7" },
84
+ "expected": true
85
+ },
86
+ {
87
+ "id": "0011-prerelease-included",
88
+ "title": "^1.2 accepts a prerelease of a matching version",
89
+ "since": "0.1.0",
90
+ "tags": ["caret", "prerelease", "non-standard"],
91
+ "input": { "version": "1.2.3-beta.1", "range": "^1.2" },
92
+ "expected": true,
93
+ "notes": "DIFFERS FROM STANDARD SEMVER, which returns false because prereleases are excluded from ranges unless the range itself carries one. Reaching for a stock semver library gives the wrong answer here: a node the host would accept is refused at load."
94
+ },
95
+ {
96
+ "id": "0012-caret-zero-zero",
97
+ "title": "^0.0.1 accepts a higher 0.0.x patch",
98
+ "since": "0.1.0",
99
+ "tags": ["caret", "pre-1.0", "non-standard"],
100
+ "input": { "version": "0.0.2", "range": "^0.0.1" },
101
+ "expected": true,
102
+ "notes": "DIFFERS FROM STANDARD SEMVER, where ^0.0.1 pins 0.0.1 exactly. Here the caret rule keys on the major being 0 and compares the MINOR, so 0.0.x is an open patch range. Wrong in the opposite direction from case 0011: a node loads that standard semver would have refused."
103
+ },
104
+ {
105
+ "id": "0013-empty-range",
106
+ "title": "An empty range accepts anything",
107
+ "since": "0.1.0",
108
+ "tags": ["wildcard", "edge"],
109
+ "input": { "version": "1.0.0", "range": "" },
110
+ "expected": true
111
+ },
112
+ {
113
+ "id": "0014-bare-version-exact",
114
+ "title": "A bare version is an exact match",
115
+ "since": "0.1.0",
116
+ "tags": ["exact"],
117
+ "input": { "version": "1.2.3", "range": "1.2.3" },
118
+ "expected": true
119
+ },
120
+ {
121
+ "id": "0015-whitespace-trimmed",
122
+ "title": "Surrounding whitespace is trimmed",
123
+ "since": "0.1.0",
124
+ "tags": ["edge"],
125
+ "input": { "version": "1.2.3", "range": " ^1.2 " },
126
+ "expected": true
127
+ },
128
+ {
129
+ "id": "0016-two-digit-major-union",
130
+ "title": "A union parses a two-digit major",
131
+ "since": "0.1.0",
132
+ "tags": ["union", "edge"],
133
+ "input": { "version": "10.0.0", "range": "^9 || ^10" },
134
+ "expected": true,
135
+ "notes": "Guards the naive single-digit regex. An implementation that matches \\d rather than \\d+ passes every other row in this table and fails only here."
136
+ },
137
+ {
138
+ "id": "0017-unparseable-fails-closed",
139
+ "title": "An unparseable range is unsatisfied, not waved through",
140
+ "since": "0.1.0",
141
+ "tags": ["edge", "fail-closed"],
142
+ "input": { "version": "1.0.0", "range": "not-a-range" },
143
+ "expected": false,
144
+ "notes": "Fails CLOSED. The tempting alternative — treat anything unrecognised as permissive — turns a typo in a manifest into a node that loads on hosts it was never checked against."
145
+ }
146
+ ]
147
+ }