Wave 20: CLI & fidelity correctness — numFmt parity, batch/arg ergonomics, sheet lifecycle, comment/sheetFormatPr round-trip - #440
Conversation
removeAt shifts scopes above the removal down and drops names scoped to the removed sheet (Excel's delete-sheet semantics); insertAt shifts scopes at/above the insertion point up; reorder applies the full old-to-new permutation. Verbatim (unmodelable) print names in metadata.definedNames follow their sheet like any scoped name; the modeled PrintNames.fromSheets re-derivation path is untouched and now pinned by test. Refs #434 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sheet.apply is transparent inline: a literal specializes to Sheet, a dynamic String to XLResult[Sheet], with nothing at the call site hinting the return type changed (cost a field build three edit cycles). Sheet.named(name) spells the XLResult in a plain non-inline signature and is now the documented path for runtime names; the union apply's scaladoc, the scripting guide, and the xl-scripting skill (quick ref + literal-vs-dynamic pattern + gotcha, API.md table) steer dynamic callers there. The inline apply and the SheetLiteral macro are untouched; version pins stay 0.16.0. Refs #420 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The values[] arm of batch put constructed BatchOp.PutValues without
reading the op-level "format" field, silently writing General cells
while the single-value arm and every putf variant applied the numFmt
("format" sits in knownPutProps, so no unknown-prop warning fired
either).
parseJsonValue now takes the parsed op-level format and applies it
with single-put semantics per element: native numbers carry it
directly, strings are parsed according to it (including the explicit
date-format rejection path), booleans and nulls stay bare. Because
the format lands in each ParsedValue at parse time, both the
in-memory path (applyPutValues) and the streaming batch path
(StreamingWriteCommands) honor it with no further changes.
Refs #416
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…run properties Two comment round-trip fidelity nits from the 0.15.0 field-gotcha audit: - XlsxWriter no longer synthesizes a canonical author-prefix run when the comment text already leads with its own (first run == author + optional colon, any formatting) — a file-authored 'Reviewer:' run no longer comes back as 'Reviewer:\nReviewer: …' after a rewrite. - Comment run serialization now prefers the preserved raw <rPr> (same pattern as SharedStrings), carrying <color indexed="81"/>, rgb colors, original fonts and <charset> through the rewrite instead of rebuilding a b/sz/rFont-only rPr from the Font model. Refs #433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l NumFmt tables findOrAddNumFmt hand-rolled its own builtin-id table and diverged from NumFmt.builtInId on three arms: Decimal emitted 4 (ThousandsDecimal, '#,##0.00'), Percent emitted 10 (PercentDecimal, '0.00%'), Currency emitted 44 (accounting). A cell styled through the streaming path thus rendered differently from the same style written in-memory. Delete the table and delegate every non-Custom arm to NumFmt.builtInId. extractNumFmt (the read side in the same file) carried the mirror-image divergence: only 8 ids mapped, so builtin ids 1/2/3/9/11/12 written by the in-memory path degraded to General on getStyle, and declared <numFmt> entries lost to the hand-rolled arms. Delegate to the DOM StyleParser's resolution order: declared entry (verbatim Custom, GH-404) -> NumFmt.fromId -> General. Pins: every builtin variant lands its ECMA-376 id and round-trips through getStyle; a streamed positional 'put A1 45.5%' (PR #438 routes detected formats through StylePatcher) lands numFmtId 9. Refs #408 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/width
Sheet.defaultRowHeight/defaultColumnWidth were read/model-only: neither
writer backend emitted <sheetFormatPr>, so a from-scratch book could not
set the sheet-default row height (house grids want 12.75) or default
column width, and the reader never populated the fields.
- mergeSheetFormatPrElem (WorksheetHelpers): overlays the modeled
defaults onto any preserved <sheetFormatPr> (unmodeled attrs like
baseColWidth/outlineLevelRow ride through), identity fast-path keeps
an unchanged source element verbatim, model-driven heights get the
customHeight="1" companion (ECMA-376 18.3.1.81), and a fresh
width-only element backfills the REQUIRED defaultRowHeight ("15").
- OoxmlWorksheet.fromDomainWithMetadata wires the merge on both the
preserved and fresh branches (CT_Worksheet slot before <cols>).
- DirectSaxEmitter emits the same element on the streaming path.
- XlsxReader.convertToDomainSheet populates the model fields from
<sheetFormatPr> so read-modify-write keeps them.
- SheetFormatPrRoundTripSpec: round-trip on both backends, Excel-
authored files populate the fields, unmodeled attrs ride dirty
writes, no gratuitous element when no defaults are set.
Refs #426
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…file
Two arg-shape ergonomics fixes from field use:
1. Write commands invoked without -o reported 'Error: Internal:
output required' — a usage error masquerading as an internal one.
requireOutput now takes the CLI verb and every one of the 40
dispatch sites passes its own ('recalc requires -o <out.xlsx>
(or -i to modify in place)'); requireOutputAction shares the
same message.
2. 'xl lint <file>' — the form every fresh user tries first — was
rejected with 'Unexpected argument'. lint takes exactly one file
and writes nothing, so it now accepts the positional form
alongside -f: giving the file both ways or not at all exits 2
with a hint naming both forms.
Refs #422
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deleting a sheet used to leave its whole dependent chain — drawing, chart, chart colors/style, exclusive media, their .rels and their [Content_Types].xml registrations — riding the preserved-part copy loop forever. On a write after a removal, parts reachable ONLY via a removed sheet's relationship closure now fall out of both the copy loop and the content types. Bounded on both sides: anything reachable from ANY surviving part survives (a shared image stays — the survivor walk runs over source rels, which same-path drawing regeneration keeps verbatim), and only the removed sheets' closure is candidate (pre-existing orphans ride through untouched — no general package GC). Refs #417 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Parser Every built-in enum arm hand-rolled its rendering and drifted from what the same ECMA-376 format code produces through FormatCodeParser (the file-declared path, kept verbatim since GH-404): PercentDecimal showed 15.6% where '0.00%' is 15.60%, Decimal detoured through Double, the serial-typed DateTime arm used Java's 12-hour 'h' pattern, Text leaked BigDecimal.toString, out-of-range date serials fabricated 1899 dates instead of Excel's ###### fill. Rewrite the arms to render through a pre-parsed cache of FormatCodeParser(NumFmt.formatCode(fmt)), making the programmatic enum and a file-declared equal code identical by construction; two property tests pin that identity for every built-in variant over numbers and DateTimes. The calendar variants render straight off the LocalDateTime (no lossy serial round-trip). Two FormatCodeParser gaps blocked the unification and are fixed here: - scientific notation: E+/E- exponent tokens ('0.00E+00' id 11, '##0.0E+0' id 48 engineering) previously rendered as garbage literals - hour clock: 'h' always rendered 12-hour; ECMA-376 SS18.8.31 says 12-hour only when the section carries AM/PM, else 24-hour Deliberate re-pins, each against its format code: PercentDecimal 0.156 -> 15.60% ('0.00%' forces two decimals); h:mm:ss of 14:30:45 -> 14:30:45 (no AM/PM => 24-hour); inferred-percent interpolator pin 50.0% -> 50.00% (PercentDecimal is '0.00%'). Refs #410 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mFmt.builtInId; NumFmtFormatter arms unified onto FormatCodeParser
…e errors name flags; lint positional file
…rder mutations; removal-orphan part pruning
Review: Wave 20 — CLI & fidelity correctness (1/2)Read the full diff (35 files, +2365/−242) against Findings most-significant first. Nothing here blocks merge; items 1–3 are the ones worth acting on. 1.
|
| variant | builtInId |
ECMA-376 §18.8.30 code for that id | formatCode |
|---|---|---|---|
Currency |
7 | $#,##0.00_);($#,##0.00) |
$#,##0.00 |
Date |
14 | mm-dd-yy (en-US Excel renders m/d/yyyy) |
m/d/yy |
So -1234.56 styled NumFmt.Currency displays -$1,234.56 in xl view/HTML but opens in Excel as ($1,234.56); a NumFmt.Date cell displays 11/21/25 but opens as 11/21/2025. The GH-410 property test cannot see this — it compares the enum arm against Custom(formatCode(fmt)), both sides of the same code.
The missing law is the cross-check: for every built-in, formatCode(fmt) should equal the ECMA code for builtInId(fmt). Since no built-in id carries a plain $#,##0.00, Currency probably wants to be emitted as a declared numFmt entry (like Custom) rather than mapped to 7. Follow-up-sized, but same class as #408/#410 and this wave is the natural place to record it.
Review: Wave 20 — CLI & fidelity correctness (2/2)6.
|
Burn-down wave 2 of 3 (plan of record 2026-07-22): nine issues across six worktree-isolated TDD clusters, pipelined adversarial review — all six approved with zero rework rounds.
What's in it
StylePatcherdeletes its hand-rolled builtin-id table and delegates toNumFmt.builtInId(Decimal→2, Percent→9, Currency→7 — streamed styles no longer render differently from in-memory ones, including--stream putdetected formats from Fix positional put smart type detection #438).NumFmtFormatterbuilt-in arms now render viaFormatCodeParser(NumFmt.formatCode(fmt)), property-tested identical to the file-declared path (PercentDecimalnow shows Excel's15.60%); display re-pins each justified against the ECMA-376 code.putvalues[] threads the op-levelformatinto every element with exact single-put semantics (in-memory + streaming); all 40 write verbs report<verb> requires -o <out.xlsx> (or -i to modify in place)instead ofInternal:;xl lint <file>positional form accepted (exactly-one-file enforced with hints).removeAt/insertAt/reorderremapDefinedName.localSheetId(removed-sheet scopes dropped, Excel semantics); remove-sheet prunes parts reachable only via the removed sheet's rel closure — chart add → remove-sheet → zero orphaned chart/drawing parts, while a shared-image-across-two-sheets fixture keeps the image.indexed/raw rPr payloads) survive rewrite byte-faithfully. (Footprint note: the synthesis lived inXlsxWriter.buildCommentsData, not Comments.scala — the cluster extended scope deliberately and flagged it.)<sheetFormatPr>emits on both backends fromSheet.defaultRowHeight/defaultColumnWidth, reads back, and preserves unmodeled attrs; untouched sheets stay byte-stable.Sheet.named(name): XLResult[Sheet]as the documented dynamic-name path; scripting docs/skills explain the literal-vs-dynamic split (markers say "since 0.17.0").Gates (all green)
./mill __.compile810/810, 0 non-exhaustive-match warnings./mill -i __.test1028/1028 tasks, 4,908 cases (+72 this wave)XL_ROUNDTRIP_MIN_SUCCESS=500generative law 239/239./scripts/test-examples.sh+./scripts/verify-skill-snippets.sh --localgreen;xl.docJarverifiedField-repro replays (branch-built CLI)
format:"0.00%"→ cells render100.00%(cli: batch put with values[] silently ignores the op-level format field #416)recalcwithout-o→recalc requires -o <out.xlsx> (or -i to modify in place);xl lint file.xlsxpositional works (cli: two arg-shape ergonomics nits — recalc's missing -o reports 'Internal:', lint rejects the positional file form #422)--stream put B1 45.5%→ styles.xml numFmtId 9 (canonical Percent; was 10) (streaming: StylePatcher builtin numFmt-id table diverges from NumFmt.builtInId (Decimal/Percent/Currency render wrong formats) #408)Reviewer:prefix run +indexed="81") rewrites with exactly one prefix and color intact (ooxml: comment rewrite prepends a duplicate author-prefix run and drops run color #433)Integration notes
All six clusters merged conflict-free. Follow-up candidates surfaced by the wave (not filed yet, listed for triage): unknown batch
formatnames are silently ignored (warnings-vector candidate);Workbook.apply/upserthave the same literal-vs-dynamic union as #420; streamingextractStyledoesn't carry raw numFmtId onto CellStyle; lint part-reachability check needs carve-out design (not trivial — #413 comment stands).Closes #408
Closes #410
Closes #416
Closes #417
Closes #420
Closes #422
Closes #426
Closes #433
Closes #434
🤖 Generated with Claude Code