Skip to content

fix: guard every integer cast that reads a document value (#37) - #44

Merged
elvogel merged 1 commit into
mainfrom
fix/37-guarded-ilvl
Oct 4, 2026
Merged

elvogel merged 1 commit into
mainfrom
fix/37-guarded-ilvl

Conversation

@elvogel

@elvogel elvogel commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Closes #37 — and #37 named one of three.

It was three casts, not one

markdown.xslt reads three integers straight out of the document, and all three aborted the
whole conversion on a value that would not cast — exit 1, no output file, so in a batch the
document is lost rather than degraded:

line value source filed?
143 w:ilvl/@w:val document yes — #37
172 w:lvl/@w:ilvl numbering.xml no
400 w:gridSpan/@w:val document no

Each contradicted the promise the stylesheet makes three lines above one of them — one
malformed document must never stop a corpus conversion
— which the duplicate-w:numId and
undefined-numId cases already keep.

Probing for siblings before fixing is the habit this repository earned the hard way: the "one
definition of what counts as text" refactor found five readers where the commit message had
claimed four, and the fifth meant a release silently dropped characters.

Each now falls back exactly as its own absent-attribute case already did: level 0, a bullet, a
single cell.

Two engine constraints rule out the obvious spelling

Both are recorded in the code rather than left to be rediscovered:

  • [@w:ilvl castable as xs:integer and xs:integer(@w:ilvl) eq $ilvl] is wrong. XPath does
    not guarantee short-circuit evaluation of and, so the cast may still run and still throw.
  • [A][B] chained predicates are quadratic on this engine (phoenixmldb-xslt#10/#95;
    w:p[A][B] measured O(n²)).

So it is one predicate using if … then … else false(), where conditional evaluation is
something the spec actually promises.

Verified

Tests first, and they failed for the right reasons: three distinct line numbers in the thrown
XQueryException — 143, 172 and 400.

Full suite 372 tests, 371 pass, 1 skip
Corpus output 13 of 13 byte-identical — no change on well-formed documents
End-to-end all three malformed fixtures now exit 0 and produce output

Performance: undetectable, not "unchanged"

The guards sit on hot paths — docmd:ilvl runs per list paragraph, the w:lvl predicate per
paragraph × per level — on documents of ~46,000 paragraphs, so this was worth measuring rather
than assuming.

run elapsed
baseline, no guards 387,352 ms
guarded 403,663 ms
guarded 402,757 ms
guarded 382,154 ms

Guarded runs span 382k–404k (5.6%) and the baseline falls inside that range; the fastest
guarded run beats the unguarded baseline. The honest claim is that any effect is below this
machine's noise floor, not that there is none. Measured now deliberately, because the
performance work landing next release would otherwise mask a regression introduced here.

The C# side was checked and is clean: the only int.Parse reads docmd's own frontmatter output,
never document input.

🤖 Generated with Claude Code

https://claude.ai/code/session_018wZEgtuzaZPswykiGEBxbz

#37 named one cast. Probing for siblings found three, all the same shape, all
aborting the whole conversion with exit 1 and no output file:

  line 143  w:ilvl/@w:val          document      the one filed
  line 172  w:lvl/@w:ilvl          numbering.xml not filed
  line 400  w:gridSpan/@w:val      document      not filed

Each contradicted the promise the stylesheet makes three lines above one of
them: one malformed document must never stop a corpus conversion. The
duplicate-w:numId and undefined-numId cases already keep that promise; these
did not. In a batch the document is lost rather than degraded.

Each now falls back exactly the way its own absent-attribute case already did:
level 0, a bullet, a single cell. Output is unchanged on every well-formed
document: 13 of 13 corpus documents byte-identical.

Two engine constraints rule out the obvious spelling, and both are recorded
rather than rediscovered:

  - `[@w:ilvl castable as xs:integer and xs:integer(@w:ilvl) eq $ilvl]` is
    wrong. XPath does not guarantee short-circuit evaluation of `and`, so the
    cast may still run and still throw.
  - `[A][B]` chained predicates are quadratic on this engine (recorded as
    phoenixmldb-xslt#10/#95; w:p[A][B] measured O(n**2)).

So it is one predicate using `if ... then ... else false()`, where conditional
evaluation is something the spec actually promises.

Tests first, and they failed for the right reasons before the fix: three
distinct line numbers in the thrown XQueryException, 143, 172 and 400.

  NonNumericIlvlInTheDocument_DegradesToLevelZero
  NonNumericIlvlInNumbering_DegradesToABullet
  NonNumericGridSpan_DegradesToASingleCell

ListStylesheetTests.ToMarkdownAsync takes an optional numbering part now, which
the second test needs to malform numbering.xml rather than the document.

The C# side was checked and is clean: the only int.Parse reads docmd's own
frontmatter output, never document input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wZEgtuzaZPswykiGEBxbz
@elvogel
elvogel merged commit d634bcb into main Oct 4, 2026
1 check passed
@elvogel
elvogel deleted the fix/37-guarded-ilvl branch October 4, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A non-numeric w:ilvl aborts the whole conversion instead of degrading

1 participant