@particle-academy/fancy-conformance 0.0.0 → 0.1.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,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
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/satisfies-range",
4
+ "title": "Minimal semver range matching",
5
+ "since": "0.1.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "satisfiesRange(version: string, range: string): boolean",
10
+ "summary": "Does a concrete version satisfy a manifest's declared range?",
11
+ "implementations": [
12
+ { "language": "node", "package": "@particle-academy/fancy-ui-cli", "symbol": "satisfiesRange" },
13
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "satisfiesRange" },
14
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "NodeManifest::satisfiesRange" }
15
+ ]
16
+ },
17
+ "notes": [
18
+ "This suite is the one promoted verbatim from code that already existed, because it is the proof the method works. Three independent implementations of this function have NOT drifted, and the only thing they do differently from the suite's other triplicated contracts is that each carries this identical table in its own CI.",
19
+ "Two rows deliberately disagree with standard semver. They are tagged `non-standard` and a fourth implementation reaching for a stock semver library will fail exactly those two. That is the intended outcome: the disagreement should be a red build, not a discovery."
20
+ ]
21
+ }