@particle-academy/holy-sheet 2.4.3 → 2.6.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/CHANGELOG.md ADDED
@@ -0,0 +1,550 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@particle-academy/holy-sheet` are documented here.
4
+ (The repository is `holy-sheet-js`; the published package has never carried the
5
+ `-js` suffix, and this line used to say it did — a name a consumer would have
6
+ typed into `npm install` and got a 404 for.)
7
+
8
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
9
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
10
+
11
+ ## [Unreleased]
12
+
13
+ ## [2.6.0] — 2026-10-11
14
+
15
+ ### Fixed
16
+
17
+ - **A cell value that cannot be represented is REFUSED and names the cell,
18
+ instead of being silently stringified. In this runtime the old behaviour wrote
19
+ `"x"` for the cell `["x"]` — the corruption looked correct.** Mirrors the PHP
20
+ reference fix in `particle-academy/holy-sheet` 2.5.0 ([holy-sheet#9], reported
21
+ from MOIC production); tracked here as [#2].
22
+
23
+ `coerceValue()` ended with "not a primitive, so it must be safe to stringify":
24
+
25
+ ```ts
26
+ if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
27
+ return String(v); // an ARRAY reaches this line
28
+ }
29
+ ```
30
+
31
+ An array gets there whenever a plain `rows` form is nested one level too
32
+ deep — `rows: [[["x"]]]` instead of `rows: [["x"]]`. No object-cell syntax is
33
+ needed, which is exactly the shape an AI-generated edit produces.
34
+
35
+ **Why this port's version was worse than PHP's.** `Array.prototype.toString`
36
+ joins, so a one-level nesting produced *exactly what the author meant*:
37
+
38
+ | cell | before | PHP before its fix |
39
+ |---|---|---|
40
+ | `["x"]` | `"x"` | `"Array"` |
41
+ | `["x","y"]` | `"x,y"` | `"Array"` |
42
+
43
+ PHP wrote visible garbage, which someone files a bug about. This wrote a
44
+ workbook that was silently, undetectably correct-looking — and for a
45
+ two-element row it merged two cells into one with the data intact enough to
46
+ look deliberate. A wrong answer that looks right is the expensive kind.
47
+
48
+ The guard now accepts an object that defines its own `toString` (PHP's
49
+ `Stringable`) and refuses everything else, with **two exclusions that are both
50
+ load-bearing**: a function, because `Function.prototype.toString` returns the
51
+ function's SOURCE TEXT — the old code wrote a code listing into the cell — and
52
+ an array, because `Array.prototype.toString` is *also* "its own", so "has a
53
+ custom toString" is true of the one value that must never pass. A symbol is
54
+ refused too, where it previously wrote `"Symbol(x)"`.
55
+
56
+ The refusal is a `SchemaException` whose structured error names the cell as
57
+ `Sheet!A1`, with a hint explaining the nesting mistake. That differs from the
58
+ `sheets[0].rows[1]` schema path used elsewhere, deliberately: this fault is
59
+ always one cell, and the A1 location is what an operator can act on.
60
+
61
+ - **A `Date`, or any object with a `toString`, in a plain `rows` row no longer
62
+ writes an EMPTY CELL.** Found while fixing the above, and it is the more
63
+ ordinary bug of the two.
64
+
65
+ The cell-spec dispatcher tested `isPlainObject(value)` — `typeof === "object"
66
+ && !null && !Array.isArray` — which is true for **every** non-array object. So
67
+ a `Date`, a class instance and a `Map` were all read as the `{value, format}`
68
+ cell-spec form, `value.value` was `undefined`, and the cell came out blank: no
69
+ error, no validation failure, just a hole where the date should be. PHP's
70
+ equivalent guard is `is_array && !array_is_list`, and a PHP
71
+ `DateTimeInterface` is an object rather than an array, so it fell straight
72
+ through to the date branch and wrote the serial. **The pair's contract is
73
+ "same bytes either backend", and the byte-parity suite never saw this because
74
+ its fixtures carry ISO date *strings* rather than `Date` objects.**
75
+
76
+ The dispatcher now claims only a plain object literal — which is also what
77
+ `JSON.parse` produces for every schema arriving as JSON — and anything with
78
+ its own prototype is treated as a value.
79
+
80
+ - **`validate()` no longer passes a schema that `normalize()` will throw on.**
81
+ The same input returned **zero** validation errors, so `Agent.validate()` told
82
+ a caller the workbook was fine and the write then failed — moving the failure
83
+ to where it is hardest to attribute, in the exact validate-then-write order an
84
+ agent uses. `Validator` now reports `sheets[0].rows[0][0]` for an array cell.
85
+
86
+ The check is `Array.isArray`, **not** "is an object": the object cell form
87
+ `{value, format}` has always been legal, and rejecting any object would have
88
+ traded this bug for a far worse one — every formatted cell in every workbook.
89
+ There is a test holding that line.
90
+
91
+ **What you must do:** nothing, if your cells were already scalars, dates or
92
+ cell objects. If any schema you build can nest a row one level too deep, you
93
+ now get a `SchemaException` naming the cell where you previously got a
94
+ workbook with the joined value in it — read `getErrors()[0].path` for the
95
+ address. Released as a MINOR rather than a patch because that throw is
96
+ observable to a caller whose input the validator previously accepted.
97
+
98
+ - **`CHANGELOG.md` is now in the published tarball.** `files` did not whitelist it, so npm never shipped it: a consumer who followed a link to the changelog — from the README, from npm, or from an upgrade guide — found nothing. Nothing for you to do; the file simply arrives from this release on.
99
+ - Dropped `docs` from `files`: it was declared and never existed, so the package claimed to ship it and did not.
100
+
101
+ ### Security
102
+
103
+ - `source-map-js` is pinned forward to `^1.2.2` via `overrides`. Versions up to
104
+ 1.2.1 allow an event-loop denial of service through indexed source-map section
105
+ offsets, and it arrives here transitively through the build toolchain.
106
+ **Nothing for a consumer to do, and no runtime change**: an npm package does
107
+ not ship a lockfile, so this governs builds OF this repo, not anything
108
+ installed FROM it. Recorded rather than left silent because the override it
109
+ sits beside — `shell-quote` `^1.9.0`, added for an earlier advisory — was
110
+ carried with no note of why, and had drifted back inside the vulnerable range
111
+ before anyone looked.
112
+
113
+ [#2]: https://github.com/Particle-Academy/holy-sheet-js/issues/2
114
+ [holy-sheet#9]: https://github.com/Particle-Academy/holy-sheet/issues/9
115
+
116
+ ## [2.5.0] — 2026-10-02
117
+
118
+ ### Fixed
119
+
120
+ - **A column's own `width` is honoured. It was documented and silently ignored.**
121
+
122
+ `skills/holy-sheet.schema.json` — the tool definition handed to an LLM, shared
123
+ byte-identically with the PHP and Python ports under a checksum test —
124
+ describes `columns[].width` as *"Column width in pixels. Same as columnWidths
125
+ but per-column."* Only the sheet-level `columnWidths` map was ever read, so a
126
+ width written the way the schema documents it emitted **no `<cols>` element at
127
+ all**: no exception, no validation error, and nothing from `validateAndRepair`.
128
+ Silent at every layer.
129
+
130
+ Reported against the PHP package as **holy-sheet#8** by the MOIC team, whose
131
+ owner's complaint was "can't style spreadsheets at all". Measured, that was
132
+ mostly this: the theme, the header fill, the banded rows and the currency
133
+ formats all landed, but a real account name truncated and a correctly-formatted
134
+ currency rendered as `#####` — which is what a reader actually sees. Fixed in
135
+ PHP 2.4.0; this is the matching twin.
136
+
137
+ **Precedence, identical to PHP: the sheet-level `columnWidths` map is applied
138
+ LAST and wins.** It is the mechanism that already worked, so a consumer who
139
+ moved to it to route around this bug must not then find a leftover `width`
140
+ quietly overriding them. The two sources merge rather than replace, and `<col>`
141
+ elements are emitted in ascending column order — merging two sources means
142
+ insertion order is no longer column order.
143
+
144
+ A `width` now goes through the same `ColumnWidths` rule the map already obeyed,
145
+ so `"abc"` or `-5` is rejected rather than written, and **`validate()` reports
146
+ it at `sheets[0].columns[0].width`**. That part matters as much as the fix: the
147
+ original defect's real cost was that an agent composes against the schema doc,
148
+ gets no error, and never learns the field was dropped.
149
+
150
+ **What you must do: nothing, and do not migrate off `columnWidths`.** If you
151
+ set `width` and saw no effect, it now works. If you moved to `columnWidths`, it
152
+ still wins. Output bytes change for anyone who had `width` set — which is why
153
+ this is a minor rather than a patch.
154
+
155
+
156
+ ## [2.4.3] — 2026-09-14
157
+
158
+ ### Fixed
159
+
160
+ - **A `columnWidths` key that is not a column index wrote a `NaN` column.** The
161
+ normalizer read keys with `parseInt`, so `{"abc": 999}` became a width for
162
+ column `NaN` in the written file. PHP overwrote column A with it and Python
163
+ raised; all three follow one rule now, mirroring PHP holy-sheet 2.3.4
164
+ (`src/schema/column-widths.ts`):
165
+ - a **key** is a 0-based column index from 0 to 16383, as a string of digits;
166
+ - a **width** is a non-negative finite number, or a string of digits (`"80.5"`).
167
+ - `validate()` reports each other entry by path (`sheets[0].columnWidths.abc`),
168
+ so `write()` and `toBytes()` refuse it.
169
+ - `validateAndRepair()` turns a one- or two-letter key into its index (`"B"` is
170
+ 1) and drops an entry it cannot repair, and lists both.
171
+ - The normalizer skips such an entry.
172
+
173
+ **What you must do:** nothing, unless a schema carried a width keyed by a letter
174
+ or by junk. `write()` now refuses it; run it through `validateAndRepair()`, or
175
+ key widths by index.
176
+
177
+ ## [2.4.2] — 2026-09-14
178
+
179
+ ### Fixed
180
+
181
+ Six defects in the op code, mirroring `particle-academy/holy-sheet` 2.3.2 (five,
182
+ found by the Python port) and 2.3.3 (one), which are the reference. Each has a
183
+ test ported from PHP's `SheetOpsTest.php`, and each fails against 2.4.1 except
184
+ the second, which this port never had.
185
+
186
+ - **`Agent.opSchema()` rejected a `set_column_widths` op carrying a list.** 2.4.1
187
+ allowed only an empty array, but PHP encodes widths keyed 0..n-1 as a list
188
+ (`[120, 80, 140]`), and a schema read from PHP's JSON carries that list through
189
+ this port's `diff()` into its op. `columnWidths` is now an object or an array
190
+ of non-negative numbers, a list indexed by position. The schema matches PHP
191
+ 2.3.2's byte for byte, description included.
192
+ - **A `type` that is not an op type is skipped before anything else.** PHP's
193
+ loose `switch` let `type: true` remove a sheet. This port's `switch` was
194
+ always strict, so it never had that defect; `reduce()` now checks the type
195
+ against `SheetOpSchema.TYPES` first, as PHP does.
196
+ - **A padded address wrote a key of its own.** `set_cell` stored `" a1 "` under
197
+ `" A1 "`, and `clear_cell` could not reach A1 with it. Both now trim with PHP's
198
+ `trim()` set (space, tab, LF, CR, NUL, vertical tab; not NBSP) before
199
+ upper-casing.
200
+ - **A column-width key that is not a column index was read as column A** by
201
+ `insert_columns` and `delete_columns` (`"abc"` became column 0), and could
202
+ overwrite column A's width. It is dropped. A digit string (`"007"`) and a
203
+ negative integer key are still indexes, as they are in PHP.
204
+ - **Two values JSON cannot hold compared as the same.** `SheetDiff.same()`, and
205
+ so `diff()`, wrote `NaN`, `Infinity`, functions and symbols as `null`, so a
206
+ cell going from `NaN` to `Infinity` recorded no change. It now throws a
207
+ `TypeError` for a non-finite number, a lone UTF-16 surrogate in a string or a
208
+ key (a JS string cannot hold invalid UTF-8, and this is its equivalent), a
209
+ function or a symbol. Nesting deeper than PHP's `json_encode` depth of 4096
210
+ throws too, at the same level PHP does (every object and array counts, an
211
+ empty one included). The comparison is now a loop: the recursion it replaces
212
+ overflowed the call stack near that depth, at a level that varied from run to
213
+ run.
214
+ - **An op with a position or count that is not a number moved or unfroze
215
+ things** (PHP 2.3.3). `add_sheet.index`, `move_sheet.toIndex`,
216
+ `set_frozen.rows`/`cols` and the row and column ops' `at`/`count` were cast as
217
+ PHP's `(int)` does, so `toIndex: "last"` moved a sheet to the front,
218
+ `index: "end"` inserted one there, and `rows: "one"` unfroze the panes. A
219
+ present value that is not an integer or a string of digits, `null` included,
220
+ now skips the op; absent keys keep their defaults (`add_sheet` appends,
221
+ `move_sheet` stays, `set_frozen` uses 0). As in PHP, the check covers every op
222
+ that reaches a sheet, so a junk `count` on a `set_cell` skips it too. One
223
+ divergence cannot be removed: `JSON.parse` gives `2` for `2.0`, so a position
224
+ written `2.0` applies here, where PHP decodes a float and skips the op.
225
+
226
+ `tests/sheet-ops-parity.test.ts` gains PHP-checked reduce cases for each fix,
227
+ diff cases for a list of widths, a padded cell key and a width key that is not
228
+ an index, and `SheetDiff.same` at 4095 to 4097 levels, which the PHP helper
229
+ builds itself so the nesting never crosses JSON.
230
+
231
+ **What you must do:** nothing, unless you relied on one of the above. `diff()`
232
+ output changes only for a schema holding a padded cell key or a width key that
233
+ is not an index, where it matches PHP 2.3.3's, and `diff()` now throws on a
234
+ schema holding a value JSON cannot, which JSON input never does.
235
+
236
+ ## [2.4.1] — 2026-09-14
237
+
238
+ ### Fixed
239
+
240
+ - **`Agent.opSchema()` accepts a `set_column_widths` op with an empty array**, as
241
+ PHP 2.3.1 now declares. PHP's diff emits `columnWidths: []` when every width is
242
+ removed (its JSON for an empty map), which the schema rejected; an op stream
243
+ written by the PHP package failed validation here. `columnWidths` may be an
244
+ object or an empty array. The parity suite compares the schema with PHP's
245
+ byte for byte, so the two stay one schema.
246
+
247
+ **What you must do:** nothing.
248
+
249
+ ## [2.4.0] — 2026-09-14
250
+
251
+ ### Added
252
+
253
+ - **`Agent.diff()`, `Agent.reduce()`, `Agent.opSchema()` and `Agent.equivalent()`:
254
+ a workbook's versions stored as ops** (holy-sheet [#7](https://github.com/Particle-Academy/holy-sheet/issues/7)).
255
+ Ported from `particle-academy/holy-sheet` 2.3.0, which is the reference: the
256
+ same algorithm, so the same two schemas give the same ops in the same order
257
+ in both runtimes, and an op history written by one replays in the other.
258
+ Hashing xlsx bytes cannot keep a one-cell edit small, because a zip changes
259
+ nearly every byte, so a version history had to store a whole file per edit.
260
+ `diff(newer, older)` is the op list that restores `older` from `newer`.
261
+ - `reduce(a, diff(a, b))` equals `b`, key order aside. The ops are verified
262
+ by replaying them: a sheet the granular ops cannot reproduce is replaced
263
+ whole, and so, as a last resort, is the workbook.
264
+ - One changed cell is one `set_cell`. Rows and columns are aligned by content
265
+ first, so an inserted row is one `insert_rows` plus its cells rather than
266
+ every cell below it rewritten.
267
+ - Schemas that write the same workbook diff to `[]`, so a save without a change
268
+ records nothing. A columns/rows sheet and the cells it becomes are the same,
269
+ and so is the creation time the writer stamps on a schema that names none.
270
+ - `set_cell`, `set_range` and `set_workbook` are fancy-sheets' `SheetOp` shapes
271
+ and behave as its reducer does (a `set_cell` without a formula clears it and
272
+ keeps the format). The rest are holy-sheet's: `clear_cell`,
273
+ `insert_rows`/`delete_rows`, `insert_columns`/`delete_columns`,
274
+ `add_sheet`/`remove_sheet`/`rename_sheet`/`move_sheet`/`replace_sheet`,
275
+ `set_merged_regions`, `set_column_widths`, `set_frozen` and `set_meta`.
276
+ - Row and column ops move cells, merged regions and column widths. They do not
277
+ rewrite formula text; a formula that changes with an insert is its own
278
+ `set_cell`.
279
+ - **`SheetOp`**, a discriminated union on `type` (and one interface per op),
280
+ plus **`SheetDiff`**, **`SheetReducer`** and **`SheetOpSchema`**, exported
281
+ beside the other building blocks.
282
+
283
+ `tests/sheet-ops-parity.test.ts` runs the PHP package's `Agent::diff` and
284
+ `Agent::reduce` on every case and requires the same ops, field for field and in
285
+ order: each edit PHP's own suite pins, both ways; seeded random cell edits;
286
+ seeded row and column inserts and deletes; moved rows and raw alignments, where
287
+ the delete-first tie-break decides the result; and the op JSON Schema, byte for
288
+ byte. Two differences cannot be removed, because JS values do not carry the
289
+ distinction:
290
+
291
+ - PHP counts `1` and `1.0` as different values, so its diff rewrites a `1` as
292
+ `1.0` with a `set_cell` where this one records nothing.
293
+ - An emptied column-width map is emitted as `{}`, where PHP's JSON writes its
294
+ empty array as `[]`. Both reducers read either as empty.
295
+
296
+ **What you must do:** nothing. This only adds methods.
297
+
298
+ ## [2.3.1] — 2026-09-14
299
+
300
+ ### Fixed
301
+
302
+ - **`Agent.lint()` accepts quoted sheet names** (holy-sheet [#6](https://github.com/Particle-Academy/holy-sheet/issues/6)).
303
+ `=SUM('My Earnings Projection'!A2:A3)` linted as `#NAME?` because the
304
+ tokenizer had no case for `'`, so any cross-sheet formula pointing at a sheet
305
+ whose name contains a space failed. Excel's doubled-quote escape works too:
306
+ `'Q3 ''Final'''!B2` is the sheet `Q3 'Final'`.
307
+ - **A reference to a sheet that does not exist is `#REF!`**, quoted or not, and
308
+ the hint names the sheet and lists the ones that exist. It used to lint clean,
309
+ because the missing sheet's cells read as blanks.
310
+ - **Sheet names match case-insensitively**, as in Excel.
311
+
312
+ The same fix as `particle-academy/holy-sheet` 2.2.1, with the hint text
313
+ identical in all three runtimes and pinned by a test in each.
314
+
315
+ **What you must do:** nothing, unless you relied on a formula that names a
316
+ missing sheet linting clean. It now reports `#REF!`.
317
+
318
+ ## [2.3.0] — 2026-09-13
319
+
320
+ ### Added
321
+
322
+ - **`Agent.read()` and `Agent.describe()` read OpenDocument spreadsheets
323
+ (`.ods`) into the same schema as `.xlsx`.** Ported from
324
+ `particle-academy/holy-sheet`'s new reader, which has the full list of what
325
+ maps (values, formulas translated to A1, repeats, merges, comments, styles,
326
+ data styles, metadata) and what does not (frozen panes, column widths,
327
+ fonts, function-name translation). The format is sniffed from the bytes, so
328
+ the caller's branch on the file type can go.
329
+
330
+ Diffed against the PHP reader on the PHP repo's own fixtures, key order
331
+ included, in `tests/ods-reader-parity.test.ts`: a LibreOffice-converted
332
+ workbook, a hand-written native file, and a hand-built package of what
333
+ LibreOffice rewrites on save. `tests/ported-php/OdsReaderTest.test.ts` ports
334
+ the PHP assertions. Both load the fixtures from the PHP checkout
335
+ (`HOLY_SHEET_PHP_SRC`, or the sibling directory), as the parity suites
336
+ already did.
337
+
338
+ - **`UnsupportedFormatException`**, exported, thrown for bytes that are
339
+ neither format, with the declared `mimetype` when there is one.
340
+ - **`OdsReader`** and **`FormatSniffer`**, exported beside `XlsxReader`, and
341
+ `XlsxReader.readFiles()` for an already-unzipped package.
342
+ - **`parseXml(src, { namespaces: true })`** resolves namespaces and keeps mixed
343
+ content in order. OpenDocument needs both; without the option the parser
344
+ behaves exactly as before.
345
+
346
+ ### Changed
347
+
348
+ - **Unreadable bytes now throw `UnsupportedFormatException` instead of a bare
349
+ `Error`.** It extends `Error`, so **an existing `catch` keeps working: do
350
+ nothing.** The message for bytes that are not a zip still says "not a zip
351
+ archive"; only code matching the rest of the old text (`(no EOCD record)`,
352
+ `missing xl/workbook.xml`) sees different wording.
353
+
354
+ ### Fixed
355
+
356
+ - **The tool schema announced its own shipped features as unreleased.**
357
+ `src/holy-sheet.schema.json` is a byte-identical copy of the PHP twin's, and
358
+ carried the same defect: every formatting field described in the future tense
359
+ ("Lands in 0.3.", "Lands in 0.5.") under a heading declaring a v0.2.0 that
360
+ could not style anything, while the package shipped at 2.x with all of it
361
+ working.
362
+
363
+ Updated in lockstep with `particle-academy/holy-sheet`; both checksum pins
364
+ moved together. See that package's entry for the measurement.
365
+
366
+ - **An empty `cells: []` validates.** PHP's `describe()` reports a sheet with no cells as `cells: []`, and this validator rejected any array, so a schema read in PHP and written here failed on an empty sheet. The PHP validator had the same defect and is fixed in holy-sheet 2.2.0. A non-empty list is still an error.
367
+
368
+ ## 2.2.1 — 2026-09-10
369
+
370
+ ### Fixed
371
+
372
+ - **`version()` reports the version this package actually ships as.** It
373
+ returned `1.0.0` from a 2.2.x release. The constant had drifted because
374
+ nothing compared it to the packaging metadata — the same shape as every other
375
+ two-copies-of-one-number failure in this estate.
376
+
377
+ `VersionIsSingleSourcedTest` / `version.test.ts` now pins it, so the class is
378
+ closed rather than the instance fixed. `dark-slide-py` already had that
379
+ assertion and was the only engine in the family to catch itself.
380
+
381
+
382
+ ## 2.2.0 — 2026-09-10
383
+
384
+ ### Added
385
+
386
+ - **A checksum test pinning `holy-sheet.schema.json` to its PHP twin.** The
387
+ schema is a byte-identical copy of `holy-sheet/skills/holy-sheet.schema.json`,
388
+ kept in sync by remembering to edit both. Nothing checked that — and this is
389
+ the file handed to an LLM as the tool definition, so a one-sided edit did not
390
+ fail a build, it changed what an agent was told the API is on one backend and
391
+ not the other.
392
+
393
+ To change the schema: edit both copies, run either suite, paste the new hash
394
+ into both tests. The mild annoyance is the mechanism.
395
+
396
+ The hash is taken over **newline-normalised** content. Neither repo has a
397
+ `.gitattributes`, so the file is stored LF and lands CRLF on a Windows
398
+ checkout — a raw-bytes checksum would have passed on CI and failed on the
399
+ maintainer's own machine, which is a worse failure than the one being
400
+ prevented.
401
+
402
+ ### Fixed
403
+
404
+ - **A sheet with BOTH a table and explicit cells no longer discards the table.**
405
+ `normalizeSheet` returned the moment `cells` was set, silently throwing away
406
+ `columns`, `rows`, `totals` and `theme`. A four-row report with one styled
407
+ title cell wrote a **one-cell workbook**, and `validate()` reported no errors,
408
+ because the validator explicitly permits both shapes together.
409
+
410
+ The PHP twin had the identical bug in the identical place. That is why no
411
+ parity test caught it: **parity detects disagreement, and the two runtimes
412
+ agreed.**
413
+
414
+ **What you must do: nothing.** A sheet using only one of the two shapes
415
+ behaves exactly as before. Explicit cells win at their address, and their
416
+ format is merged over the table's, so a cell asking only for `bold` keeps the
417
+ column's currency format instead of dropping to bare.
418
+
419
+ - **Bare scalar cells are written instead of silently emptied.** `{A1: 42}` read
420
+ `.value` off a number, got `undefined`, and wrote a blank cell — no error,
421
+ just a hole where the number should be. **This one was Node-only:** PHP has
422
+ always had the branch, and the port dropped it.
423
+
424
+ - **A bare `"=SUM(...)"` string is promoted to a real formula**, in both cell
425
+ maps and rows. Also Node-only: the same schema produced a working formula in
426
+ PHP and the literal text `=SUM(...)` in Node.
427
+
428
+ - **A `comment` given as a plain string is no longer dropped** — only the object
429
+ form was read, and the string form was accepted by the validator and lost.
430
+
431
+ ## 2.1.0 — 2026-08-09
432
+
433
+ ### Fixed
434
+
435
+ - **`1e300` was written to the sheet as `1e+3`.** The worst bug in this package's
436
+ history, and it never threw. `toFixed` switches to exponential notation at
437
+ 1e21, and the trailing-zero strip then chewed the EXPONENT rather than a
438
+ fraction:
439
+
440
+ ```
441
+ (1e300).toFixed(14) === "1e+300"
442
+ "1e+300".replace(/0+$/, "") === "1e+3"
443
+ ```
444
+
445
+ So a cell holding 1e300 was written holding 1000. Not a crash, not invalid
446
+ XML — a different number, in a file someone opens later and believes.
447
+
448
+ - **`1e21` and above were written as `<v>1e+21</v>`**, which is not valid cell
449
+ content. This had a second cause the first fix did not reach: the writer only
450
+ called `formatFloat` for NON-integers, and `Number.isInteger(1e21)` is `true`,
451
+ so it took a `String(v)` branch. Mirroring PHP's `is_float(...)` test leaks —
452
+ a PHP int never stringifies exponentially, an integral JS double does.
453
+
454
+ - **Numeric strings lost their exponent.** `numericStringToNumber` used
455
+ `parseInt` when the string had no `.`, and `parseInt` stops at the `e`:
456
+
457
+ | input | was | now |
458
+ |---|---|---|
459
+ | `"1e5"` | `1` | `100000` |
460
+ | `"1E5"` | `1` | `100000` |
461
+ | `"2e-3"` | `2` | `0.002` |
462
+ | `"1e21"` | `1` | `1e21` |
463
+
464
+ It is now `Number(v)` — which is what `FormulaLinter` already used for the
465
+ same job, so the two disagreed with each other as well as with PHP.
466
+
467
+ - **`NaN` and `Infinity` were written into cells.** Both now write `0`, matching
468
+ the PHP twin.
469
+
470
+ ### Added
471
+
472
+ - A `numericHazards` case in the cross-engine parity fixtures. The unit tables on
473
+ both sides were green while the engines still disagreed on `1e21`; only the
474
+ byte-for-byte diff caught it. Numbers are now a parity *case*, not a
475
+ convention.
476
+
477
+ ### Changed
478
+
479
+ - **BREAKING in effect, though the API is identical:** values listed above now
480
+ serialize differently, because they were wrong. If you have a golden xlsx
481
+ containing an exponent-notation string, a value ≥ 1e21, or `NaN`, regenerate
482
+ it. Anything below 1e21 with no exponent string is byte-identical to 2.0.2.
483
+
484
+ ## 2.0.2 — 2026-08-09
485
+
486
+ ### Fixed
487
+
488
+ - **`scripts/php-describe.php` was missed by 2.0.1**, so the reader-parity suite
489
+ still could not find the PHP classes outside the `.agi` envelope and CI failed
490
+ on the 2.0.1 commit. It now honours `HOLY_SHEET_PHP_SRC` like its sibling.
491
+
492
+ Worth saying plainly: CI caught this the very first time the parity suites
493
+ actually ran. Under the old `skipIf` the incomplete fix would have gone green.
494
+
495
+ ## 2.0.1 — 2026-08-09
496
+
497
+ ### Fixed
498
+
499
+ - **The cross-engine parity suites now actually run in CI.** They are the
500
+ strongest guarantee this pair has — every OOXML part diffed byte-for-byte
501
+ against the PHP `holy-sheet` — and they had never executed on a runner.
502
+
503
+ Two things combined to hide it. `describe.skipIf(!HAS_PHP)` skipped silently,
504
+ and `ci.yml` installed Node only, so the build went green with **zero
505
+ cross-engine coverage**. Separately, the parity helper autoloaded the PHP
506
+ sources from a hard-coded `../../holy-sheet/src`, which only resolves inside
507
+ the `.agi` envelope — so even with PHP present, a different layout found no
508
+ classes.
509
+
510
+ Now: CI installs PHP 8.4 and checks out the PHP repo; the helper takes
511
+ `HOLY_SHEET_PHP_SRC` (falling back to the sibling path); and a missing PHP
512
+ **throws in CI** instead of skipping. A green build that asserts nothing is
513
+ worse than a red one, because nobody investigates green.
514
+
515
+ **What you must do:** nothing, unless you run these suites outside the
516
+ envelope — then set `HOLY_SHEET_PHP_SRC` to the PHP package's `src`.
517
+
518
+ ## 2.0.0 — 2026-08-07
519
+
520
+ ### Changed
521
+
522
+ - **BREAKING — Node 18 is no longer supported.** `engines.node` moves from `>=18` to `>=22`.
523
+
524
+ **What you must do:** on Node 22 or newer, nothing. Note npm only *warns* on an `engines` mismatch while **pnpm fails the install**, so this surfaces differently depending on your package manager. Node 18 is end-of-life and 20 is maintenance-only.
525
+
526
+ ### Why
527
+
528
+ These are the kit 0.5 platform floors, applied across every package at once so a consumer never has to resolve a mix. **No API changed, nothing was removed, nothing was renamed** — only what the package requires.
529
+
530
+ This package is past 1.0, so a floor raise takes a **major**. Most of the suite is pre-1.0 and lands the identical change in a minor — that is semver, not a difference in how much changed.
531
+
532
+ ## 1.0.0 — 2026-05-30
533
+
534
+ Initial Node/TypeScript port of `particle-academy/holy-sheet` (PHP), at
535
+ **feature-parity with PHP 1.2.0**. Zero-dependency, isomorphic (browser + Node).
536
+
537
+ - Full `Agent` surface: `validate`, `toBytes`, `write` (Node), `validateAndRepair`,
538
+ `lint`, `fromArray`, `fromCsv`, `read`/`describe`, `toolDefinition`, `version`,
539
+ plus the `HolySheet` instance class.
540
+ - xlsx **writer** — inline-string/number/bool/date/formula cells, deduped
541
+ styles (fonts/fills/borders/numFmts/xfs), currency/percent/date number
542
+ formats, 4 themes, symbolic totals, merged cells, frozen panes, column
543
+ widths, comments (+ VML), multi-sheet.
544
+ - xlsx **reader** — round-trips the above back to a schema (own + Excel-authored
545
+ files), via a hand-rolled isomorphic ZIP (STORE write / inflate read) and a
546
+ tiny XML parser.
547
+ - **Formula linter** — `#VALUE!/#REF!/#DIV/0!/#NAME?/#CIRC!` with off-by-one
548
+ hints; 15+ functions.
549
+ - **Verified byte-identical** to the PHP engine across sparse, themed/totaled,
550
+ decorated, and multi-sheet workbooks (cross-engine parity suite).
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @particle-academy/holy-sheet
2
2
 
3
- [![Fancy UI suite](art/fancy-ui.svg)](https://particle.academy)
3
+ [![Fancified](art/fancified.svg)](https://particle.academy)
4
4
 
5
5
  Zero-dependency, **isomorphic** (browser + Node) `.xlsx` writer + reader +
6
6
  formula linter for agentic document creation. The Node/TypeScript mirror of the
7
7
  PHP [`particle-academy/holy-sheet`](https://github.com/Particle-Academy/holy-sheet)
8
- — same schema in, same `.xlsx` out (feature-parity with PHP 1.2.0; the PHP
9
- package has since shipped 1.3.0, whose additions are not yet mirrored here).
8
+ — same schema in, same `.xlsx` out (the PHP package's `dumpJson` and `Toolkit`
9
+ are not mirrored here; `CHANGELOG.md` records which PHP releases each fix mirrors).
10
10
 
11
11
  ```ts
12
12
  import { Agent } from "@particle-academy/holy-sheet";