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