@particle-academy/fancy-conformance 0.2.0 → 0.4.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,241 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/flow-run-identity",
4
+ "cases": [
5
+ {
6
+ "id": "0001-top-level-step",
7
+ "title": "A node at the top level of a run keys on the run and its own id",
8
+ "since": "0.4.0",
9
+ "tags": ["stepkey"],
10
+ "fn": "stepKey",
11
+ "input": { "runKey": "run_9f2c1b0e77a4d310", "path": [], "nodeId": "pay", "occurrence": null, "attempt": 1 },
12
+ "expected": "run_9f2c1b0e77a4d310:pay"
13
+ },
14
+ {
15
+ "id": "0002-occurrence-zero-is-not-absent",
16
+ "title": "Occurrence 0 is a real occurrence and appears in the key",
17
+ "since": "0.4.0",
18
+ "tags": ["stepkey", "loop"],
19
+ "fn": "stepKey",
20
+ "input": { "runKey": "run_a", "path": [], "nodeId": "pay", "occurrence": 0, "attempt": 1 },
21
+ "expected": "run_a:pay#0",
22
+ "notes": "A falsy-but-present occurrence is the classic place a language's truthiness quietly drops the segment, collapsing iteration 0 into the un-iterated key."
23
+ },
24
+ {
25
+ "id": "0003-occurrence-distinguishes-iterations",
26
+ "title": "A different loop iteration is a different logical step",
27
+ "since": "0.4.0",
28
+ "tags": ["stepkey", "loop"],
29
+ "fn": "stepKey",
30
+ "input": { "runKey": "run_a", "path": [], "nodeId": "pay", "occurrence": 7, "attempt": 1 },
31
+ "expected": "run_a:pay#7"
32
+ },
33
+ {
34
+ "id": "0004-one-invocation-segment",
35
+ "title": "A node inside a subflow carries the invoking node as a path segment",
36
+ "since": "0.4.0",
37
+ "tags": ["stepkey", "subflow"],
38
+ "fn": "stepKey",
39
+ "input": { "runKey": "run_a", "path": ["billing"], "nodeId": "pay", "occurrence": null, "attempt": 1 },
40
+ "expected": "run_a:billing/pay"
41
+ },
42
+ {
43
+ "id": "0005-nested-invocations",
44
+ "title": "Nesting composes outermost-first",
45
+ "since": "0.4.0",
46
+ "tags": ["stepkey", "subflow"],
47
+ "fn": "stepKey",
48
+ "input": { "runKey": "run_a", "path": ["billing", "dunning#2"], "nodeId": "pay", "occurrence": null, "attempt": 1 },
49
+ "expected": "run_a:billing/dunning#2/pay",
50
+ "notes": "Path segments arrive already rendered - an invocation that repeats renders its own occurrence before it is pushed."
51
+ },
52
+ {
53
+ "id": "0006-node-id-slash-is-escaped",
54
+ "title": "A slash in a node id is escaped, never a separator",
55
+ "since": "0.4.0",
56
+ "tags": ["stepkey", "escaping"],
57
+ "fn": "stepKey",
58
+ "input": { "runKey": "run_a", "path": [], "nodeId": "a/b", "occurrence": null, "attempt": 1 },
59
+ "expected": "run_a:a%2Fb"
60
+ },
61
+ {
62
+ "id": "0007-the-collision-escaping-prevents",
63
+ "title": "A node `b` inside an invocation of `a` is NOT the node named `a/b`",
64
+ "since": "0.4.0",
65
+ "tags": ["stepkey", "escaping"],
66
+ "fn": "stepKey",
67
+ "input": { "runKey": "run_a", "path": ["a"], "nodeId": "b", "occurrence": null, "attempt": 1 },
68
+ "expected": "run_a:a/b",
69
+ "notes": "Read together with 0006. Unescaped, both spell run_a:a/b - two unrelated writes sharing one idempotency key, which the provider collapses into a single charge."
70
+ },
71
+ {
72
+ "id": "0008-node-id-hash-is-escaped",
73
+ "title": "A hash in a node id is escaped, so # only ever means occurrence",
74
+ "since": "0.4.0",
75
+ "tags": ["stepkey", "escaping"],
76
+ "fn": "stepKey",
77
+ "input": { "runKey": "run_a", "path": [], "nodeId": "a#2", "occurrence": null, "attempt": 1 },
78
+ "expected": "run_a:a%232"
79
+ },
80
+ {
81
+ "id": "0009-percent-is-escaped-first",
82
+ "title": "The escape character is itself escaped, and before everything else",
83
+ "since": "0.4.0",
84
+ "tags": ["stepkey", "escaping"],
85
+ "fn": "stepKey",
86
+ "input": { "runKey": "run_a", "path": [], "nodeId": "a%2Fb", "occurrence": null, "attempt": 1 },
87
+ "expected": "run_a:a%252Fb",
88
+ "notes": "An implementation that escapes / before % turns a literal a%2Fb into the same text as the escaped a/b from 0006 - the collision, reintroduced by the fix for it."
89
+ },
90
+ {
91
+ "id": "0010-escaping-and-occurrence-compose",
92
+ "title": "Escaping applies to the id, the occurrence marker stays structural",
93
+ "since": "0.4.0",
94
+ "tags": ["stepkey", "escaping", "loop"],
95
+ "fn": "stepKey",
96
+ "input": { "runKey": "run_a", "path": [], "nodeId": "a/b", "occurrence": 2, "attempt": 1 },
97
+ "expected": "run_a:a%2Fb#2"
98
+ },
99
+ {
100
+ "id": "0011-attempt-one-key",
101
+ "title": "The key on the first attempt",
102
+ "since": "0.4.0",
103
+ "tags": ["stepkey", "retry"],
104
+ "fn": "stepKey",
105
+ "input": { "runKey": "run_a", "path": [], "nodeId": "pay", "occurrence": null, "attempt": 1 },
106
+ "expected": "run_a:pay"
107
+ },
108
+ {
109
+ "id": "0012-attempt-does-not-change-the-key",
110
+ "title": "The fifth attempt of the SAME step sends the SAME key",
111
+ "since": "0.4.0",
112
+ "tags": ["stepkey", "retry"],
113
+ "fn": "stepKey",
114
+ "input": { "runKey": "run_a", "path": [], "nodeId": "pay", "occurrence": null, "attempt": 5 },
115
+ "expected": "run_a:pay",
116
+ "notes": "The load-bearing row of the whole suite, and it only means anything read against 0011. An implementation that mixes attempt into the key passes every other case here and double-charges in production on the first timeout."
117
+ },
118
+ {
119
+ "id": "0013-first-attempt-is-always-replay-safe",
120
+ "title": "Attempt 1 is safe however long the run was parked - nothing was sent before",
121
+ "since": "0.4.0",
122
+ "tags": ["replaysafe", "human-gate"],
123
+ "fn": "isReplaySafe",
124
+ "input": { "attempt": 1, "firstAttemptAt": "2026-08-01T00:00:00Z", "now": "2026-08-19T00:00:00Z", "windowSeconds": 86400 },
125
+ "expected": true,
126
+ "notes": "This is the human-gate case: a run parks on an approval for eighteen days, resumes, and the writing node executes for the first time. There is no earlier attempt for the provider to have forgotten."
127
+ },
128
+ {
129
+ "id": "0014-retry-inside-the-window",
130
+ "title": "A prompt retry is inside the provider dedup window",
131
+ "since": "0.4.0",
132
+ "tags": ["replaysafe", "retry"],
133
+ "fn": "isReplaySafe",
134
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-19T00:00:00Z", "now": "2026-08-19T00:00:30Z", "windowSeconds": 86400 },
135
+ "expected": true
136
+ },
137
+ {
138
+ "id": "0015-the-boundary-is-inclusive",
139
+ "title": "Exactly at the window is still inside it",
140
+ "since": "0.4.0",
141
+ "tags": ["replaysafe", "boundary"],
142
+ "fn": "isReplaySafe",
143
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-18T00:00:00Z", "now": "2026-08-19T00:00:00Z", "windowSeconds": 86400 },
144
+ "expected": true
145
+ },
146
+ {
147
+ "id": "0016-one-second-past-the-window",
148
+ "title": "One second past the window is outside it",
149
+ "since": "0.4.0",
150
+ "tags": ["replaysafe", "boundary"],
151
+ "fn": "isReplaySafe",
152
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-18T00:00:00Z", "now": "2026-08-19T00:00:01Z", "windowSeconds": 86400 },
153
+ "expected": false
154
+ },
155
+ {
156
+ "id": "0017-stripe-twenty-five-hours",
157
+ "title": "A retry 25h after the first attempt is not protected by the Stripe 24h window",
158
+ "since": "0.4.0",
159
+ "tags": ["replaysafe", "stripe"],
160
+ "fn": "isReplaySafe",
161
+ "input": { "attempt": 4, "firstAttemptAt": "2026-08-18T00:00:00Z", "now": "2026-08-19T01:00:00Z", "windowSeconds": 86400 },
162
+ "expected": false,
163
+ "notes": "Stripe forgets an idempotency key after 24 hours. Resending it creates a second charge, and sending a fresh one creates a second charge - so the only safe answer is to refuse and let a person reconcile."
164
+ },
165
+ {
166
+ "id": "0018-parked-a-week-then-first-write",
167
+ "title": "A week parked, then the node first attempt",
168
+ "since": "0.4.0",
169
+ "tags": ["replaysafe", "human-gate"],
170
+ "fn": "isReplaySafe",
171
+ "input": { "attempt": 1, "firstAttemptAt": "2026-08-12T09:15:00Z", "now": "2026-08-19T09:15:00Z", "windowSeconds": 86400 },
172
+ "expected": true
173
+ },
174
+ {
175
+ "id": "0019-zero-window-permits-no-retry",
176
+ "title": "A zero window means only the first attempt may write",
177
+ "since": "0.4.0",
178
+ "tags": ["replaysafe", "boundary"],
179
+ "fn": "isReplaySafe",
180
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-19T00:00:00Z", "now": "2026-08-19T00:00:00Z", "windowSeconds": 0 },
181
+ "expected": false,
182
+ "notes": "Zero is a window, not an absent one. An implementation treating 0 as null turns this provider does not dedupe into this provider dedupes forever."
183
+ },
184
+ {
185
+ "id": "0020-zero-window-first-attempt",
186
+ "title": "A zero window still permits the first attempt",
187
+ "since": "0.4.0",
188
+ "tags": ["replaysafe", "boundary"],
189
+ "fn": "isReplaySafe",
190
+ "input": { "attempt": 1, "firstAttemptAt": "2026-08-19T00:00:00Z", "now": "2026-08-19T00:00:00Z", "windowSeconds": 0 },
191
+ "expected": true
192
+ },
193
+ {
194
+ "id": "0021-null-window-never-expires",
195
+ "title": "A provider that does not expire keys is always replay-safe",
196
+ "since": "0.4.0",
197
+ "tags": ["replaysafe"],
198
+ "fn": "isReplaySafe",
199
+ "input": { "attempt": 9, "firstAttemptAt": "2020-01-01T00:00:00Z", "now": "2026-08-19T00:00:00Z", "windowSeconds": null },
200
+ "expected": true
201
+ },
202
+ {
203
+ "id": "0022-clock-skew-clamps-to-zero",
204
+ "title": "A now earlier than the first attempt is treated as no elapsed time",
205
+ "since": "0.4.0",
206
+ "tags": ["replaysafe", "clock"],
207
+ "fn": "isReplaySafe",
208
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-19T00:00:10Z", "now": "2026-08-19T00:00:00Z", "windowSeconds": 86400 },
209
+ "expected": true,
210
+ "notes": "Two workers with a few seconds of skew must not turn a legitimate retry into a refusal."
211
+ },
212
+ {
213
+ "id": "0023-offset-and-zulu-are-the-same-instant",
214
+ "title": "A +00:00 offset parses as the same instant as Z",
215
+ "since": "0.4.0",
216
+ "tags": ["replaysafe", "clock"],
217
+ "fn": "isReplaySafe",
218
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-18T00:00:00+00:00", "now": "2026-08-19T00:00:01Z", "windowSeconds": 86400 },
219
+ "expected": false
220
+ },
221
+ {
222
+ "id": "0024-non-utc-offset-is-honoured",
223
+ "title": "A non-UTC offset is converted, not ignored",
224
+ "since": "0.4.0",
225
+ "tags": ["replaysafe", "clock"],
226
+ "fn": "isReplaySafe",
227
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-19T02:00:00+02:00", "now": "2026-08-19T00:00:30Z", "windowSeconds": 60 },
228
+ "expected": true,
229
+ "notes": "02:00+02:00 is midnight UTC, so 30 seconds have elapsed. An implementation that drops the offset reads it as two hours and refuses."
230
+ },
231
+ {
232
+ "id": "0025-fractional-seconds-parse",
233
+ "title": "Fractional seconds do not break the comparison",
234
+ "since": "0.4.0",
235
+ "tags": ["replaysafe", "clock"],
236
+ "fn": "isReplaySafe",
237
+ "input": { "attempt": 2, "firstAttemptAt": "2026-08-18T00:00:00.500Z", "now": "2026-08-19T00:00:00.400Z", "windowSeconds": 86400 },
238
+ "expected": true
239
+ }
240
+ ]
241
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/flow-run-identity",
4
+ "title": "Stable run/step identity for fancy-flow, and when a retry may reuse it",
5
+ "since": "0.4.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "summary": "A workflow node that WRITES to a third party needs an idempotency key that is the same on every retry of one logical step and different for every other execution of the same node. `stepKey` derives it from the run identity; `isReplaySafe` says whether a retry is still inside the provider's dedup window.",
10
+ "functions": {
11
+ "stepKey": "stepKey(runKey: string, path: string[], nodeId: string, occurrence: int|null) -> string",
12
+ "isReplaySafe": "isReplaySafe(attempt: int, firstAttemptAt: string, now: string, windowSeconds: number|null) -> bool"
13
+ },
14
+ "reference": "node",
15
+ "referenceNote": "The format was designed here rather than lifted from an implementation; `node` is recorded because the TypeScript RunIdentity was written first and the other two were made to match it.",
16
+ "implementations": [
17
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "RunIdentity" },
18
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Runtime\\RunIdentity" },
19
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.runtime.RunIdentity" }
20
+ ]
21
+ },
22
+ "notes": [
23
+ "`attempt` is deliberately NOT part of the step key, and case 0011/0012 are the pair that proves it. An idempotency key that changes per attempt creates a second charge on every retry — which is the exact failure the key exists to prevent. `attempt` is carried on the identity for logging and for `isReplaySafe`, never for the key.",
24
+ "`(run, node)` alone is NOT sufficient identity and the suite says so structurally: `path` and `occurrence` exist because a node legitimately executes more than once in one run — once per subflow invocation, once per loop iteration. Cases 0004-0005 pin nesting, 0002-0003 pin repetition.",
25
+ "Segments are escaped (`%` -> `%25`, `/` -> `%2F`, `#` -> `%23`) so the composition is injective. Without it a node literally named `a/b` at the top level and a node `b` inside an invocation of `a` produce the same key — two different writes sharing an idempotency key, which the provider deduplicates into one. Cases 0006-0010 are that pair and its relatives. Escape `%` FIRST or the escaping is not reversible.",
26
+ "`isReplaySafe` returns TRUE on attempt 1 whatever the elapsed time, and that is the case that lets a run park on a human gate for a week: nothing was sent on a previous attempt, so there is nothing for the provider to have forgotten. Cases 0013 and 0018.",
27
+ "The window is INCLUSIVE at its boundary (0015) and clock skew clamps to zero rather than going negative (0022). A null window means the provider does not expire keys (0021).",
28
+ "A FALSE from `isReplaySafe` must become a loud failure at the call site, never a silently omitted key and never a fresh one: past the window the provider has forgotten the first attempt, so both alternatives write twice. Refusing converts a silent double-charge into a visible stuck run."
29
+ ]
30
+ }
@@ -0,0 +1,275 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/image-header",
4
+ "cases": [
5
+ {
6
+ "id": "0001-png-1x1",
7
+ "title": "PNG, the smallest possible image",
8
+ "since": "0.3.0",
9
+ "tags": [
10
+ "png"
11
+ ],
12
+ "fn": "sniffImageSize",
13
+ "input": {
14
+ "base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAElFTkSuQmCC"
15
+ },
16
+ "expected": {
17
+ "width": 1,
18
+ "height": 1
19
+ },
20
+ "notes": "The floor. A 1x1 PNG is what every fixture elsewhere in this org uses as a stand-in image, so if this ever fails nothing else here is worth reading."
21
+ },
22
+ {
23
+ "id": "0002-png-300x200",
24
+ "title": "PNG, dimensions past a single byte",
25
+ "since": "0.3.0",
26
+ "tags": [
27
+ "png"
28
+ ],
29
+ "fn": "sniffImageSize",
30
+ "input": {
31
+ "base64": "iVBORw0KGgoAAAANSUhEUgAAASwAAADICAYAAABS39xVAAAAAElFTkSuQmCC"
32
+ },
33
+ "expected": {
34
+ "width": 300,
35
+ "height": 200
36
+ }
37
+ },
38
+ {
39
+ "id": "0003-png-large",
40
+ "title": "PNG, dimensions past 16 bits (uint32 IHDR fields)",
41
+ "since": "0.3.0",
42
+ "tags": [
43
+ "png",
44
+ "edge"
45
+ ],
46
+ "fn": "sniffImageSize",
47
+ "input": {
48
+ "base64": "iVBORw0KGgoAAAANSUhEUgABEXAAAAADCAYAAAC/i9xAAAAAAElFTkSuQmCC"
49
+ },
50
+ "expected": {
51
+ "width": 70000,
52
+ "height": 3
53
+ },
54
+ "notes": "IHDR width and height are uint32. A sniffer that reads them as uint16, or that sign-extends the high byte, gets this wrong and nothing smaller catches it."
55
+ },
56
+ {
57
+ "id": "0004-png-truncated",
58
+ "title": "PNG signature but fewer than 24 bytes",
59
+ "since": "0.3.0",
60
+ "tags": [
61
+ "png",
62
+ "edge",
63
+ "rejects"
64
+ ],
65
+ "fn": "sniffImageSize",
66
+ "input": {
67
+ "base64": "iVBORw0KGgoAAAANSUhEUgAAAAE="
68
+ },
69
+ "expected": null,
70
+ "notes": "Fewer than 24 bytes. The read of offsets 16..23 must be bounds-checked BEFORE it happens, not after."
71
+ },
72
+ {
73
+ "id": "0005-png-not-ihdr",
74
+ "title": "PNG signature, first chunk is not IHDR",
75
+ "since": "0.3.0",
76
+ "tags": [
77
+ "png",
78
+ "hazard",
79
+ "rejects"
80
+ ],
81
+ "fn": "sniffImageSize",
82
+ "input": {
83
+ "base64": "iVBORw0KGgoAAAANZ0FNQQAAAAQAAAAECAYAAAB3rFQUAAAAAElFTkSuQmCC"
84
+ },
85
+ "expected": null,
86
+ "notes": "PNG signature, first chunk is gAMA rather than IHDR, with plausible numbers where IHDR's would be. Both hand-rolled sniffers verify the chunk name and return null; PHP's getimagesizefromstring reads offsets 16/20 blindly and answers 4x4. Checking the chunk name is the difference between reading a header and reading whatever is at that offset."
87
+ },
88
+ {
89
+ "id": "0006-png-zero-width",
90
+ "title": "PNG whose IHDR declares width 0",
91
+ "since": "0.3.0",
92
+ "tags": [
93
+ "png",
94
+ "edge",
95
+ "rejects"
96
+ ],
97
+ "fn": "sniffImageSize",
98
+ "input": {
99
+ "base64": "iVBORw0KGgoAAAANSUhEUgAAAAAAAAAJCAYAAAAchC3aAAAAAElFTkSuQmCC"
100
+ },
101
+ "expected": null,
102
+ "notes": "A zero dimension is not a size. It divides by zero in any aspect-ratio fit, and the writers do exactly that when widthPx is omitted."
103
+ },
104
+ {
105
+ "id": "0007-png-bad-signature",
106
+ "title": "one byte of the PNG signature flipped",
107
+ "since": "0.3.0",
108
+ "tags": [
109
+ "png",
110
+ "edge",
111
+ "rejects"
112
+ ],
113
+ "fn": "sniffImageSize",
114
+ "input": {
115
+ "base64": "iVBOWA0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAElFTkSuQmCC"
116
+ },
117
+ "expected": null
118
+ },
119
+ {
120
+ "id": "0008-jpeg-baseline",
121
+ "title": "JPEG, baseline SOF0",
122
+ "since": "0.3.0",
123
+ "tags": [
124
+ "jpeg"
125
+ ],
126
+ "fn": "sniffImageSize",
127
+ "input": {
128
+ "base64": "/9j/wAARCAHgAoADAAAAAAAAAAAA/9k="
129
+ },
130
+ "expected": {
131
+ "width": 640,
132
+ "height": 480
133
+ }
134
+ },
135
+ {
136
+ "id": "0009-jpeg-progressive",
137
+ "title": "JPEG, progressive SOF2",
138
+ "since": "0.3.0",
139
+ "tags": [
140
+ "jpeg",
141
+ "hazard"
142
+ ],
143
+ "fn": "sniffImageSize",
144
+ "input": {
145
+ "base64": "/9j/wgARCADwAUADAAAAAAAAAAAA/9k="
146
+ },
147
+ "expected": {
148
+ "width": 320,
149
+ "height": 240
150
+ },
151
+ "notes": "SOF2, not SOF0. A sniffer that only matches 0xC0 silently fails on every progressive JPEG, which is most photographs on the web."
152
+ },
153
+ {
154
+ "id": "0010-jpeg-exif-first",
155
+ "title": "JPEG with an APP1/Exif segment before the frame header",
156
+ "since": "0.3.0",
157
+ "tags": [
158
+ "jpeg"
159
+ ],
160
+ "fn": "sniffImageSize",
161
+ "input": {
162
+ "base64": "/9j/4QAcRXhpZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/wAARCAAyAGQDAAAAAAAAAAAA/9k="
163
+ },
164
+ "expected": {
165
+ "width": 100,
166
+ "height": 50
167
+ },
168
+ "notes": "An APP1/Exif segment before the frame header - the ordinary shape of a camera JPEG. Pins segment-length skipping."
169
+ },
170
+ {
171
+ "id": "0011-jpeg-fill-bytes",
172
+ "title": "JPEG with 0xFF fill bytes padding the frame marker",
173
+ "since": "0.3.0",
174
+ "tags": [
175
+ "jpeg",
176
+ "live-divergence",
177
+ "hazard"
178
+ ],
179
+ "fn": "sniffImageSize",
180
+ "input": {
181
+ "base64": "/9j/////wAARCABkAMgDAAAAAAAAAAAA/9k="
182
+ },
183
+ "expected": {
184
+ "width": 200,
185
+ "height": 100
186
+ },
187
+ "skip": {
188
+ "node": "@particle-academy/last-word's jpegSize advances by 2 on a 0xFF fill byte instead of 1, steps over the real frame marker and returns null. Fill bytes before a marker are legal per ITU T.81 B.1.1.2. Fix is a one-character change (i += 2 -> i++ on the marker === 0xff branch); until it ships this case is red there."
189
+ },
190
+ "notes": "THE FIRST DIVERGENCE. ITU T.81 B.1.1.2: 'any marker may optionally be preceded by any number of fill bytes, which are bytes assigned code X\"FF\"'. On seeing 0xFF as the marker byte, the PHP sniffer advances by ONE and re-syncs; the Node sniffer advances by TWO and steps over the real marker, so it returns null and the writer sizes the image from a fallback instead of its intrinsic dimensions. getimagesizefromstring agrees with PHP. Legal input, produced by real encoders, and the same JPEG therefore embeds at a different size depending on which backend wrote the document."
191
+ },
192
+ {
193
+ "id": "0012-jpeg-sos-before-sof",
194
+ "title": "JPEG whose scan starts before any frame header",
195
+ "since": "0.3.0",
196
+ "tags": [
197
+ "jpeg",
198
+ "live-divergence",
199
+ "hazard",
200
+ "rejects"
201
+ ],
202
+ "fn": "sniffImageSize",
203
+ "input": {
204
+ "base64": "/9j/2gAIAAEAAD8A/8AAEQgAFgALAwAAAAAAAAAAAP/Z"
205
+ },
206
+ "expected": null,
207
+ "skip": {
208
+ "php": "particle-academy/last-word's ImageSize::jpeg has no start-of-scan stop, so it keeps walking into entropy-coded data and reports dimensions read out of compressed bytes. Both the Node port and PHP's own getimagesizefromstring stop at 0xFFDA. Until last-word gains the SOS stop this case is red there.",
209
+ "python": "The Python port is a faithful mirror of the PHP reference and therefore inherits the same missing SOS stop -- verified, not assumed: it answers 11x22 here. Deliberately NOT fixed unilaterally. .ai/plans/polyglot/parity/documents.md 1.4 rules that a defect producing wrong output is fixed in every engine in one release train, because fixing it in one language alone breaks part-level parity, which is the suite's whole contract. This skip disappears the day last-word gains the stop."
210
+ },
211
+ "notes": "THE SECOND DIVERGENCE, and it runs the other way. After a start-of-scan the bytes are entropy-coded, so an 0xFF 0xCx sequence found there is compressed data, not a frame header. The Node sniffer stops at SOS; the PHP sniffer keeps walking and reports dimensions it read out of the scan. getimagesizefromstring also stops. The failure mode is worse than the first one: a null sniff falls back, a WRONG sniff is believed."
212
+ },
213
+ {
214
+ "id": "0013-jpeg-truncated",
215
+ "title": "JPEG SOI and nothing else",
216
+ "since": "0.3.0",
217
+ "tags": [
218
+ "jpeg",
219
+ "edge",
220
+ "rejects"
221
+ ],
222
+ "fn": "sniffImageSize",
223
+ "input": {
224
+ "base64": "/9g="
225
+ },
226
+ "expected": null
227
+ },
228
+ {
229
+ "id": "0014-gif-8x8",
230
+ "title": "a GIF, which one engine in this family knows and another does not",
231
+ "since": "0.3.0",
232
+ "tags": [
233
+ "other",
234
+ "hazard",
235
+ "rejects"
236
+ ],
237
+ "fn": "sniffImageSize",
238
+ "input": {
239
+ "base64": "R0lGODlhCAAIAPcAADs="
240
+ },
241
+ "expected": null,
242
+ "notes": "The family cannot agree whether GIF is a format it reads. last-word (both engines) says no; dark-slide's PHP calls getimagesizefromstring, which says 8x8, while dark-slide's Node port hand-rolls a GIF branch. The golden follows the reference. Recorded here because the polyglot plan lists the coverage gap and nothing tested it."
243
+ },
244
+ {
245
+ "id": "0015-not-an-image",
246
+ "title": "arbitrary bytes",
247
+ "since": "0.3.0",
248
+ "tags": [
249
+ "other",
250
+ "rejects"
251
+ ],
252
+ "fn": "sniffImageSize",
253
+ "input": {
254
+ "base64": "dGhpcyBpcyBub3QgYW4gaW1hZ2UgYXQgYWxsLCBub3QgZXZlbiBzbGlnaHRseQ=="
255
+ },
256
+ "expected": null
257
+ },
258
+ {
259
+ "id": "0016-empty",
260
+ "title": "zero bytes",
261
+ "since": "0.3.0",
262
+ "tags": [
263
+ "other",
264
+ "edge",
265
+ "rejects"
266
+ ],
267
+ "fn": "sniffImageSize",
268
+ "input": {
269
+ "base64": ""
270
+ },
271
+ "expected": null,
272
+ "notes": "Zero bytes. Every length check must survive it."
273
+ }
274
+ ]
275
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/image-header",
4
+ "title": "Intrinsic image dimensions, sniffed from PNG and JPEG headers",
5
+ "since": "0.3.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "sniffImageSize(bytes) -> {width: int, height: int} | null",
10
+ "summary": "Read an image's intrinsic pixel dimensions straight from its header bytes, with no decoder. Every document writer in this org needs it, because a model that omits widthPx/heightPx has to be sized from the image itself - so a sniffer that returns null silently changes the size a picture is embedded at, and one that returns the WRONG numbers changes it and is believed.",
11
+ "runShape": {
12
+ "width": "int",
13
+ "height": "int"
14
+ },
15
+ "reference": "php",
16
+ "referenceNote": "particle-academy/last-word's Helpers\\ImageSize::sniff, which is the most explicit hand-rolled sniffer in the family. Case 0012 is the one exception and it is marked: there the reference is demonstrably reading entropy-coded data as a header, PHP's own getimagesizefromstring and the Node port both refuse, and the golden follows the two that agree while the reference carries a skip with the reason. Every golden in this file is still the output of RUNNING an implementation - never what the value obviously ought to be.",
17
+ "implementations": [
18
+ {
19
+ "language": "php",
20
+ "package": "particle-academy/last-word",
21
+ "symbol": "Helpers\\ImageSize::sniff"
22
+ },
23
+ {
24
+ "language": "node",
25
+ "package": "@particle-academy/last-word",
26
+ "symbol": "sniffImageSize"
27
+ },
28
+ {
29
+ "language": "php",
30
+ "package": "particle-academy/dark-slide",
31
+ "symbol": "Writer\\PptxWriter (getimagesizefromstring)"
32
+ },
33
+ {
34
+ "language": "node",
35
+ "package": "@particle-academy/dark-slide",
36
+ "symbol": "writer/pptx-writer.ts getImageSize"
37
+ },
38
+ {
39
+ "language": "python",
40
+ "package": "last-word",
41
+ "symbol": "helpers.image_size.sniff"
42
+ }
43
+ ]
44
+ },
45
+ "notes": [
46
+ "FOUR implementations of this one concern already exist inside this family - two hand-rolled sniffers, one call to PHP's platform library, and a fourth hand-rolled sniffer in dark-slide-js with different format coverage again. That is the duplication .ai/plans/polyglot/parity/documents.md \u00a70.3 nominates for a shared fancy-ooxml package, and this suite is what stops a fifth copy from inheriting a defect.",
47
+ "The two divergences here (0011, 0012) were found by writing this suite on 2026-08-18. They are live at the time of writing, and they run in OPPOSITE directions: the Node port is wrong about fill bytes, the PHP reference is wrong about the start-of-scan. Neither sniffer is a superset of the other, which is why 'just follow the reference' would have shipped one of the two defects into every new implementation.",
48
+ "Bytes are carried as base64 in `input.base64` because a JSON cell cannot hold a byte string. A runner decodes and passes raw bytes to the function under test.",
49
+ "GIF, WebP, BMP, TIFF and ICO are deliberately NOT in the contract. PHP's getimagesizefromstring knows them all and both hand-rolled sniffers know none of them; widening the contract is a decision for the pair owners, not something a fixture should smuggle in."
50
+ ]
51
+ }