Skip to content

Wave 20: CLI & fidelity correctness — numFmt parity, batch/arg ergonomics, sheet lifecycle, comment/sheetFormatPr round-trip - #440

Merged
arcaputo3 merged 16 commits into
mainfrom
wave20-clifidelity
Jul 29, 2026
Merged

Wave 20: CLI & fidelity correctness — numFmt parity, batch/arg ergonomics, sheet lifecycle, comment/sheetFormatPr round-trip#440
arcaputo3 merged 16 commits into
mainfrom
wave20-clifidelity

Conversation

@arcaputo3

Copy link
Copy Markdown
Contributor

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

Gates (all green)

  • ./mill __.compile 810/810, 0 non-exhaustive-match warnings
  • scalafmt CI form — no drift
  • ./mill -i __.test 1028/1028 tasks, 4,908 cases (+72 this wave)
  • XL_ROUNDTRIP_MIN_SUCCESS=500 generative law 239/239
  • ./scripts/test-examples.sh + ./scripts/verify-skill-snippets.sh --local green; xl.docJar verified

Field-repro replays (branch-built CLI)

Integration notes

All six clusters merged conflict-free. Follow-up candidates surfaced by the wave (not filed yet, listed for triage): unknown batch format names are silently ignored (warnings-vector candidate); Workbook.apply/upsert have the same literal-vs-dynamic union as #420; streaming extractStyle doesn'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

arcaputo3 and others added 16 commits July 29, 2026 11:51
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
…rder mutations; removal-orphan part pruning
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: Wave 20 — CLI & fidelity correctness (1/2)

Read the full diff (35 files, +2365/−242) against b2028a7. High-quality work: every change is anchored to an ECMA-376 clause or a cross-checked SSF behavior, the "delete the hand-rolled table, delegate to the canonical one" moves (#408/#410) are the right direction for a library whose thesis is law-governed determinism, and the new specs are unusually good — DefinedNameScopeRemapSpec pinning verbatim print names, SheetFormatPrRoundTripSpec pinning that the identity fast-path does not inject customHeight, and the GH-410 property test comparing every enum arm against Custom(NumFmt.formatCode(fmt)) are all the tests I would have asked for.

Findings most-significant first. Nothing here blocks merge; items 1–3 are the ones worth acting on.


1. removalOrphanedParts: an unparseable survivor .rels can cause over-pruning

XlsxWriter.scala:729-748targetsOf swallows every failure into Set.empty via Try(...).toOption.flatten.map(...).getOrElse(Set.empty). That default is asymmetric across the two walks:

  • deleted closure — empty targets ⇒ fewer candidates ⇒ under-prune ⇒ safe (dead weight rides through, as before).
  • survivor walk — empty targets ⇒ a surviving part's references become invisible ⇒ anything it shares with the deleted closure gets pruned ⇒ dangling r:id in a part that ships.

parseOptionalEntry returns None on both a parseSafe failure and a Relationships.fromXml Left, and the reader is deliberately lenient about malformed rels (that is what xl lint exists to report), so this is reachable on a real file, not only on I/O failure. The doc comment promises "a part reachable from ANY surviving part survives" — that is the invariant that breaks.

Suggestion: fail-open rather than fail-closed. Either distinguish "no rels part" (legitimately empty) from "rels present but unreadable" and abandon the prune entirely in the latter case, or have the survivor walk treat an unreadable rels as reaching every manifest part. A fixture with a corrupt xl/worksheets/_rels/sheet1.xml.rels plus a shared image would pin it.

2. removalOrphanedParts reopens the source zip once per part

Same function: targetsOf calls withSourceZip(ctx.content), which for SourceContent.OnDisk constructs a fresh new ZipFile(path.toFile) — re-reading the central directory each time. survivorRoots is every surviving manifest part, so the walk opens the zip once per part that has a .rels sibling (workbook, one per worksheet, one per drawing, one per chart). On a 100-sheet, 50 MB book with drawings that is a few hundred central-directory parses on a single remove-sheet.

targetsCache prevents re-opens for the same part but not across parts. Hoisting one withSourceZip around the whole closure computation and threading SourceZip into targetsOf is a small, contained refactor for a large constant-factor win.

3. NumFmtFormatter.builtInFormats is a hand-maintained list — a new NumFmt variant degrades silently

NumFmtFormatter.scala:26-42 builds the map from a literal List(...), and both lookup sites fall back to formatGeneral / dt.toString with // unreachable comments. Combined with the new catch-all case builtin => (which replaced the previously exhaustive arms), adding a NumFmt case — say Accounting — compiles clean and silently renders as General. That is exactly the failure mode GH-404 documents: "a partial mapping with an unknown → General fallback is what silently un-formatted cells".

NumFmt.builtInId is the good pattern: an exhaustive match, so a new variant is a compile error. DisplaySpec.builtInVariants is a third hand-written copy of the list, so the property test cannot catch the drift either.

Suggestion: one canonical NumFmt.builtIns: List[NumFmt] in the companion (Scala 3 gives no values for enums with parameterized cases, so derive it from an exhaustive match, or keep the list beside builtInId with a law test builtIns.forall(builtInId(_).isDefined) plus a pinned size), read by builtInFormats, formatDateTime, and the spec alike.

4. Read/write author-prefix predicates disagree (#433)

XlsxWriter.hasLeadingAuthorPrefix (new, line 640) accepts the bare author name or author:, with no bold or second-run-newline requirement. XlsxReader.authorPrefixMatches (line 715) requires the colon, and stripAuthorPrefix additionally requires bold plus a newline-leading second run.

The wider writer predicate is right for the bug being fixed (Excel-authored prefixes the reader deliberately leaves alone). But the bare-author-without-colon arm creates a new false positive: a comment by author TODO whose text starts with a run TODO now loses its synthesized attribution line, where before this PR it got one. Requiring the colon (matching the reader) closes it; better still, lift one shared predicate — these are two spellings of "is this run an author prefix", and they will drift.

The fix also makes repeated round-trips of XL-written comments idempotent even when stripAuthorPrefix declines (non-bold first run) — a real side benefit, worth a write-read-write test pinning prefix count at 1 for the non-strippable shapes.

5. NumFmt write-id vs. display-code still diverge for Currency and Date

This wave's thesis is "the id we write and the code we display are the same format". Two of the fourteen built-ins still fail that, and #408 newly propagates them into the streaming path:

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.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: Wave 20 — CLI & fidelity correctness (2/2)

6. findOrAddNumFmt can allocate a custom id inside the reserved 0–163 range

StylePatcher.scala:319-323 (untouched here, but newly more reachable) computes the next custom id as existingIds.maxOption.map(_ + 1).getOrElse(164). If the source numFmts block declares only low ids, that lands inside the built-in reserved range.

Your own new fixture in StylePatcherSpec.scala:117 is exactly such a file (a single declared entry at numFmtId="10") — adding any other custom code to that workbook allocates id 11, overriding built-in Scientific. And #408's extractNumFmt change (declared entries now become Custom even at built-in ids) routes more files through this path than before.

One-line fix using the constant that already exists for this: clamp with .max(NumFmt.FirstCustomId), and replace the two bare 164 literals in that method with NumFmt.FirstCustomId while you are there.

7. The bare-h 24-hour fix has a wider blast radius than the CHANGELOG suggests

renderDateToken's new twelveHour gate is spec-correct (§18.8.31) and well tested, but it changes applyDateFormat for every custom code containing h/hh, not just the NumFmt.DateTime/Time enum arms — so xl view --eval, toHtml, toSvg, and PNG output of any cell styled "h:mm" all shift from 2:30 to 14:30. Similarly NumFmt.Date on a negative serial now renders ###### instead of a fabricated 1899 date.

Both are corrections, but they are silent visible changes to public rendering, and the CHANGELOG files them only under the #410 "built-in display arms" bullet. A ### Changed entry calling out the custom-code reach would save someone a bisect.

8. requireOutput verb names are 40 hand-written string literals

The #422 fix is a real UX win, but requireOutput("cf add", …), requireOutput("chart add", …), requireOutput("import-md", …) duplicate names that already exist as Opts.subcommand labels, with nothing tying them together — a renamed subcommand leaves a stale error message. Two of the forty are test-pinned (recalc, unfreeze) despite the PR body's "all 40 write verbs" claim.

Either derive the name from the CliCommand case (a total def verbName(cmd: CliCommand): String, which the compiler then keeps exhaustive) or add a table-driven test walking the write commands and asserting each message starts with its expected verb. The latter is cheap and makes the claim enforceable.

9. The missing--o error fires only after the whole workbook is read

runWithOutput(None, false) passes None straight through to run(...), which reads the file before executeCommand reaches requireOutput. So xl -f huge.xlsx recalc parses 100 MB and then prints the usage error. Since #422 is specifically about this interaction being confusing, checking the write-verb/output pairing at the Opts layer (or right after arg parsing, before the read) would make the fix land where users feel it.

10. Smaller notes

  • parseFormatName now has three identical call sites (BatchParser.scala:332, 345, 710) computing the op-level format the same way. Worth one private def opFormat(objMap) — especially since the PR already flags "unknown batch format names are silently ignored" as a warnings-vector follow-up, and that fix will now need to land in three places.
  • mergeSheetFormatPrElem writes Double.toString: a modeled defaultRowHeight = 15.0 emits defaultRowHeight="15.0" where Excel writes 15. Valid xsd:double, and the identity fast-path keeps preserved elements byte-stable — but the DOM and streaming backends can then disagree byte-for-byte for the same model, since DirectSaxEmitter always passes existing = None and so never hits the fast-path. A small canonicalizer (if h == h.floor then h.toInt.toString else h.toString) closes it.
  • mergeSheetFormatPrElem width-only over a preserved element lacking defaultRowHeight: the backfill is gated on existing.isEmpty, so this path emits a sheetFormatPr missing the attribute your own doc comment calls required. Only reachable from a malformed source (use="required"), and "preserved elements are never healed" is defensible — just flagging that the comment and the code disagree about whether that is OK.
  • remapDefinedNameScopes does not touch refersTo: removeAt correctly drops sheet-scoped names, but a workbook-scoped name whose formula points at the removed sheet (e.g. Global -> "Alpha!$A$1") rides through dangling; Excel rewrites such names to #REF!. The spec's Global name is "0.08", so this is not exercised. Same family as the documented rename limitation — follow-up, not a blocker.
  • CHANGELOG cross-refs: the streaming: StylePatcher builtin numFmt-id table diverges from NumFmt.builtInId (Decimal/Percent/Currency render wrong formats) #408 bullet cites "cli: positional put bypasses smart detection — ISO dates land as text; format:"date" on non-ISO yields Text wearing a Date numFmt #431" where the PR body says Fix positional put smart type detection #438 (--stream put detected formats); the cli: batch put with values[] silently ignores the op-level format field #416 bullet cites "Fix positional put smart type detection #438 explicit-date rejection". One of those is a typo.
  • reorder's newOrder.indexWhere cannot return -1 given the permutation guard above it — correct, but a one-line comment saying so would spare the next reader the check.

Checked and found correct

  • applyScientificPattern: e10 = precision - scale - 1 is an exact floor(log10 |x|) for every BigDecimal shape I tried (1E+2, 0.0100, 100.0) — good call avoiding the Double detour. Engineering snapping via floorDiv, the rounded >= limit renormalization, E--suppresses-plus, and pad-never-truncate all match SSF. lift(-1) returning None keeps the dispatch total.
  • formatCustom's by-name dt: => Option[LocalDateTime] means the new case builtin => arm does not eagerly run serialToDateTime for numeric formats, and serialToDateTime range-guards rather than throwing — totality holds.
  • NumFmt.Text on a number now routes through selectSectionNoneformatGeneral, which is Excel's behavior and strictly better than the old n.toString (1E+3).
  • resolveRelTarget: pure segment arithmetic, parent-escape → None, fragment/query stripped, package-absolute handled. Right call not to use java.nio.Path.
  • The prune's blocked = deletedRoots correctly stops the survivor walk expanding through removed worksheets while still letting workbook.xml.rels be a survivor root; the deleted worksheets' own .rels siblings are excluded from survivorRoots via withRelsSiblings.
  • insertAt/removeAt/reorder are genuinely the complete set of index-shifting mutations (grepped every copy(sheets = ...); the rest are appends and in-place updates).
  • Comments.encodeCommentText preserved-rPr-wins matches the established pattern at all five other sites (SharedStrings x2, DirectSaxEmitter, OoxmlCell x2), and the synthesized authorRun correctly carries rawRPrXml = None.
  • mergeSheetFormatPrElem is emitted in the correct CT_Worksheet slot on both backends (after sheetViews, before cols).
  • lintOpts' position in the orElse chain and fileOpt.orNone do not shadow any other alternative; resolveLintFile's four-case match is total and keeps lint's exit-code-2 convention.
  • The batch values[] format threading matches single-put semantics exactly, and the spec pins it against the single-value arm directly rather than restating the expectation — good test design.

Nice wave. 👍

@arcaputo3
arcaputo3 merged commit 9efd438 into main Jul 29, 2026
4 checks passed
@arcaputo3
arcaputo3 deleted the wave20-clifidelity branch July 29, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment