prism branch T-1877/bugfix-single-line-html-footnotes PR #323 (MERGEABLE / CLEAN) commits 3 + 1 merge files 4 touched lines +538 / -6 merge base a972145 (== origin/main) review round 5

Pre-push review: T-1877/bugfix-single-line-html-footnotes

Round-5 review of PR #323single-line HTML blocks disabling following footnotes. The core fix is correct and the shapes flagged in rounds 1–4 are all clean. Two new findings: a fifth attribute-value gap in the same family as rounds 2 and 3 (a / inside an unquoted value is misread as self-closing, so a real HTML block no longer opens), and a CHANGELOG entry that promises users a performance fix for a regression that only ever existed inside this PR.

At a glance

  • Core fix verified correct. The shared detector now balances open/close block tags per line, so both the .normal (line 128) and the duplicated .inDefinition re-processed-line (line 182) call sites are fixed by one change. Confirmed by hand-trace and by an empirical probe compiled against the real source.
  • Fifth attribute-value gap (major). <div title=x/> and <div data-url=https://example.com/> both return nil (no block). origin/main returns div. Independently found by two agents and reproduced directly.
  • CHANGELOG claims a fix for a never-shipped regression (major). The quadratic balancing existed only between a89a11f and 5faa6ea, both inside this PR. The same wording is repeated in a permanent test doc comment.
  • A third parser divergence is created and not documented. A definition on the line immediately after a balanced one-line HTML block is now extracted, where swift-markdown puts it inside the type-6 HTMLBlock (which ends at the first blank line, not at the closing tag). Five of the new fixtures pin exactly this; the CHANGELOG says “two ways” can still disagree.
  • Verification is real, not CI-shaped. FootnotePreprocessorTests + MarkdownBlockParserTests pass on macOS (exit 0, no xcbeautify pipe); make build-ios and make build-macos both exit 0; make lint 0 violations / 498 files. The 22 ImageDimension warnings were reproduced on the base commit, so they are pre-existing.
  • Performance question closed. Total work is provably O(line length) — matchBlockTag's name scan is always a subset of scanTagRemainder's, so no region is re-scanned. Measured ns/char is flat across an 8x size range on six adversarial shapes.
  • No existing utility was passed over. Neither HTMLSanitizer/SwiftSoup (serialized behind a Mutex per T-1681, and normalises away exactly the malformed input this must reason about) nor HTMLImageParser (XMLParser, needs well-formed input) can answer the per-line “which block tag is left open” question.
  • No bugfix report in specs/bugfixes/. The convention is live — six recent bugfix commits each ship or update one. The PR body carries the report content and states the omission was a deliberate choice for this batch run.

Verdict

Needs fixes

The T-1877 fix itself is sound: both call sites are covered by the shared detector, all 16 new fixtures pass, make lint is clean, both platform builds succeed, and the growth-ratio guard passes with ~2x headroom (measured ratio 3.85–4.11 against an 8.0 ceiling). The change is also a net performance win versus origin/main — up to 10x faster on any <-line that is not a block tag, because the old code ran up to 11 case-insensitive whole-line range(of:) passes.

Two findings block a clean push. (1) A / inside an unquoted attribute value is misread as the self-closing marker, so <div data-url=https://example.com/> opens no HTML block where origin/main, the HTML5 tokenizer and CommonMark type-6 all open one — verified empirically against the real code. That is a regression introduced by this branch, in the same attribute-value family as the round-2 and round-3 fixes, and it is undocumented. (2) The CHANGELOG tells users a quadratic-balancing stall was fixed; origin/main's detector had no tag stack at all and was linear. The quadratic form was introduced by this PR's own first commit a89a11f and fixed by its second, 5faa6ea — so no released version ever stalled, and this text is synced verbatim into the app's bundled release notes.

Both need an author decision (a production fix or an explicit documented scope-out, plus a CHANGELOG correction), so nothing was changed in the working tree by this review. The tree is clean.

Review findings

14 raised · 0 fixed · 14 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism supports footnotes: you write [^1] in your text and [^1]: the note somewhere further down, and the app turns the first into a little numbered badge you can tap.

Before rendering, Prism scans the document to find and remove those definition lines. While scanning it needs to skip over raw HTML, because a [^1]: written inside a hand-written <div> is meant to stay literal text.

The scanner did that with a simple rule: if a line starts with < and mentions a block tag like <div>, we are now inside HTML — keep skipping until a line closes it. The bug: it never checked whether the same line also closed the tag. So a self-contained one-liner like <div>Text</div> switched the scanner into “skipping HTML” mode and it never came back out. Every footnote after that point silently died — no badge, no popover, and the raw [^1]: the note line showing up as an ordinary paragraph.

Why it matters

One-line HTML is common in markdown (<div align="center">…</div>, a one-line <details>). Anyone who used one lost footnotes for the whole rest of the document, with no error message explaining why.

Key concepts

A state machine is code that remembers which mode it is in (“normal text”, “inside a code fence”, “inside HTML”). The classic bug shape here: the code that enters a mode and the code that leaves it lived in different branches, so nothing forced them to be checked against the same line of input.

The fix reads each line left to right, keeping a tally of tags opened and tags closed, and only enters HTML mode if something is still open when the line ends.

What this review found

The fix works. But tags can carry attributes, and quotes and slashes inside those attributes confuse a simple scanner. Earlier review rounds already fixed two such traps. This round found a third: a web address ending in a slash, written without quotes (<div data-url=https://example.com/>), makes the scanner think the tag closed itself when it did not. Also, the release-notes entry credits the fix with repairing a slowdown that no released version of Prism ever had.

Architecture

FootnotePreprocessor.extractDefinitions is a line-fed state machine over four states: .normal, .inCodeFence, .inHTMLBlock(tag:), .inDefinition. It runs before swift-markdown parses anything, extracting [^id]: content definitions into a FootnoteData sidecar and emitting a cleaned source.

Entry into .inHTMLBlock is decided by detectHTMLBlockOpening(_:); exit by detectHTMLBlockClosing(_:tag:), consulted only on subsequent lines. The old detector iterated blockTags and returned the first entry it found anywhere on a line starting with <, with only a tag-name boundary check. It never asked whether the line also closed that tag, and both call sites (.normal at line 128, the re-processed line in .inDefinition at line 182) then entered the state unconditionally. Result: one balanced line parked the machine permanently, phase 1 found no definitions, and process took its documented early-exit path returning .empty data and the untouched source.

The fix

Three private, nonisolated functions now cooperate:

  • detectHTMLBlockOpening — one left-to-right pass. Each opening block tag reserves a slot in openSlots: [String?] and records that index under its name in slotsByTag: [String: [Int]]; each closing tag pops its own name's most recent slot and nils it. The first slot still filled at end of line is returned. A close with no matching open costs O(1) instead of a scan of the whole open stack.
  • scanTagRemainder — consumes a tag whole, skipping quoted attribute values so a < or > inside title="<div>" is inert. Critically it is called before the guard let tag, so even non-block tags are consumed whole and cannot leak an embedded block-tag name.
  • matchBlockTag — block-tag name match with a boundary requirement (>, /, whitespace, or end of line).

Patterns worth borrowing

Fix the shared detector, not each transition. The .inDefinition branch duplicates the .normal dispatch logic; putting the balance check inside the detector fixed both without touching the duplication.

Growth-ratio assertions instead of absolute budgets. The new performance test asserts that quadrupling the tag count costs under 8x, rather than asserting a millisecond ceiling. One budget at one input size cannot distinguish linear from quadratic, and absolute budgets are exactly the tests in this suite that flake — three of them failed during this review purely from concurrent load, while the ratio test passed in every one of four parallel workers.

Fixtures that fail for the right reason. Each new fixture places the [^id] reference so that a wrongly-extracted definition still survives orphan filtering. Without that, a broken detector would produce “no definition” for the trivial reason that an orphan was dropped, and the test would pass while the bug lived.

Trade-offs

The preprocessor keeps its own notion of HTML-block extent, which is deliberately not CommonMark's. That is the point of the ticket — a self-contained element must not park the state machine — but it means the preprocessor and swift-markdown can disagree about where a block ends. Two such disagreements are documented and tracked in T-1963. This review found a third, created by this change and not documented: because a balanced line now closes the block at its closing tag rather than at the next blank line, a definition on the line immediately following is extracted where swift-markdown considers it raw HTML content. Five of the new fixtures pin that behaviour.

Empirical verification performed in this round

The three helpers were extracted from the real source, compiled standalone with swiftc -O, and probed on 22 shapes. Results that matter:

"<div title=x/>"                      -> nil     (origin/main: div)
"<div data-url=https://example.com/>" -> nil     (origin/main: div)
"<div class=a/>x</div>"               -> nil     (balanced by accident)
"<div title=\"x/\">"                   -> div     (quoted: correct)
"<divider>Text</divider>"             -> nil     (boundary check correct)
"<pretend>Text</pretend>"             -> nil     (does not match "pre")
"<section><div>"                      -> section (first-open, line order)
"<span title=\"<div>\">x</span>"       -> nil     (non-block tag consumed whole)
"<div>a <b</div>"                     -> nil     (stray < does not hide close)
"    <div>"                           -> div     (no ≤3-space limit; pre-existing)

The unquoted-value defect

Trace of <div title=x/> through scanTagRemainder: = sets expectingValue = true; the next character x is not a quote, so the quoted-value branch is skipped and expectingValue is cleared; / sets lastNonSpace = "/"; > returns isSelfClosing: lastNonSpace == "/"true, so no slot is reserved.

Per the HTML5 tokenizer's attribute value (unquoted) state, / is appended to the value: the value is x/ and the tag is not self-closing. CommonMark type-6 also opens a block. origin/main opened one too. So this is a regression relative to the base and to both reference parsers, in the same family as the round-2 (quoted values) and round-3 (quote state machine) fixes. Note the premise that <div/> opens no block is itself a departure from CommonMark type-6 — but a deliberate, tested one; misclassifying a non-self-closing tag is not.

Complexity, settled

matchBlockTag's prefix(while: alnum) region is always a subset of scanTagRemainder's: the latter stops early only at >, <, or end — none alphanumeric — and the quoted-value skip can only fire after an =, which already terminated the alnum run. index = scan.end never rewinds because scan.end ≥ nameStart > index. Measured ns/char is flat across an 8x size range on <div>x</div>×n, <div>×n + </section>×n, stray-< soup, <×n, one n-char tag name, and span soup (per-doubling ratios 1.93–2.05).

Termination is unconditional and does not rest on the isLetter guard: scan.end ∈ {endIndex} ∪ {cursor, index(after: cursor) : cursor ≥ nameStart}, and the two non-tag branches advance by one. A 300k-line fuzz over HTML syntax characters produced no hang and no crash.

Performance versus the base

Whole-document process() over 2 MB, min of 3, -O: prose 86.8 ms vs 87.8 ms (parity); <span> lines 203 ms vs 1994 ms (10x faster, because the old code ran up to 11 case-insensitive whole-line range(of:) passes); <div>-heavy 224 ms vs 177 ms (1.26x slower); one 1 MB unmatched-tag line 147 ms vs 19 ms. Net win, and it runs off the MainActor via DocumentSession's Task.detached, so this is open latency rather than a UI hang.

Two headroom items, neither blocking: Character.isWhitespace/.isLetter are Unicode property-table lookups at ~58 ns each and account for essentially all of scanTagRemainder's cost — a UTF-8 byte port was cross-checked semantically identical on 21 cases and is 7–49x faster. matchBlockTag's name scan is unbounded although the longest block tag is 10 characters, so < + a 200k-letter run costs 11.5 ms where a bounded variant costs 0.6 µs. Separately, blockTags should stay an Array: measured Array.contains 24.72 ms vs Set.contains 25.59 ms over 100k iterations, because hashing a small String costs more than 11 small-form String comparisons.

Asymmetry now exposed

detectHTMLBlockClosing is untouched and remains a bare range(of: "</tag>") — neither nesting-aware nor quote-aware. So <p title="</div>"> on a continuation line terminates the block, and a nested inner </div> ends it a level early, in both cases resuming extraction while still inside raw HTML. It is also the file's largest hotspot for HTML-heavy documents (1401 ms over a 12 MB <table> block, ~10x anything the opening detector does). Pre-existing, but the same-line rigour on one side and naivety on the other now belong in one scope — T-1963 is the natural home.

Important changes — detailed

FootnotePreprocessor: detectHTMLBlockOpening now balances tags within the line

prism/Services/FootnotePreprocessor.swift

Why it matters. This is the bug. The old detector returned a block tag for any line starting with < that mentioned one, and the closing check only ran on subsequent lines, so a balanced one-liner parked the state machine in .inHTMLBlock for the rest of the document and every later footnote definition was swallowed.

What to look at. prism/Services/FootnotePreprocessor.swift:441-489 (detectHTMLBlockOpening)

Takeaway. When a state machine's enter-state and exit-state checks live in different branches, nothing forces them to be evaluated against the same unit of input. Test the input where both should fire at once — and put the guard in the shared detector rather than at each transition, so duplicated call sites cannot drift.
Rationale. Balancing per line is the smallest change that makes the two halves of the state machine agree, and the multi-tag cases fall out of it instead of needing special cases. Returning the FIRST tag left open is deliberate: that is the block the following lines are nested inside. The obvious minimal alternative — call detectHTMLBlockClosing before entering the state — gets <div>x</div><section> wrong and would have to be applied at both call sites.

scanTagRemainder: quoted attribute values skipped per the HTML5 before-attribute-value rule

prism/Services/FootnotePreprocessor.swift

Why it matters. Two review rounds were spent here. Round 2 found that <table title="<div>">…</table> misread the embedded tag and left a phantom block open — T-1877's symptom via a different route. Round 3 found the first fix toggled 'inside a value' on EVERY quote, so <div title="\"">Content</div> reopened the same symptom. The current version only opens a value immediately after an =, and ends it at the next MATCHING delimiter with no escape processing.

What to look at. prism/Services/FootnotePreprocessor.swift:511-552 (scanTagRemainder, expectingValue)

Takeaway. HTML attribute values have no escape mechanism. A double-quoted value ends at the very next ", full stop — so title="\"" is the value \ followed by a bogus attribute named ", and title="a"b" is value a plus a bogus attribute b". Any scanner that treats a quote as a toggle rather than a state-gated delimiter will run past the real > on odd-quote input.
Rationale. This matches the repo's own audited scanner: HTMLImageSourceRewriter.attributes only calls attributeValue after finding an = (whitespace skipped), and attributeValue terminates at the next matching quote with no escape handling — the identical rule, reached independently. Verified at prism/Services/WebRendering/HTMLImageSourceRewriter.swift:177-230.

Slot-based balancing makes the scan linear in tag count

prism/Services/FootnotePreprocessor.swift

Why it matters. The round-1 implementation balanced with openTags.lastIndex(of:) + remove(at:), so a line of unmatched closing tags was quadratic — 16k pairs took 12.5s. Each open now reserves a slot and records the index under its name, and each close pops its own name's most recent slot, so a close with no matching open costs O(1).

What to look at. prism/Services/FootnotePreprocessor.swift:445-447, 473-479

Takeaway. The two rules here cannot be served by one stack: 'ignore a close with no matching open' needs per-name lookup, and 'first tag in LINE order wins' needs the positional array. openSlots + slotsByTag is therefore not redundant state. slotsByTag[tag]?.popLast() mutates in place through the dictionary's modify accessor, so the O(tags) claim holds.
Rationale. Same shape T-1655 fixed in the raw-HTML image scan, and the reason it matters is reachability: any document, including a remote one opened by URL, can contain such a line. NOTE — see finding #2: origin/main had no stack at all and was linear, so the quadratic form existed only between this PR's own first and second commits.

Growth-ratio performance guard rather than an absolute budget

prismTests/FootnotePreprocessorPerformanceTests.swift

Why it matters. One millisecond budget at one input size cannot distinguish a linear scan from a quadratic one. The new test measures 2,000 and 8,000 pairs and asserts the 4x input increase costs under 8x — 2x headroom above linear, 2x below quadratic.

What to look at. prismTests/FootnotePreprocessorPerformanceTests.swift:149-223

Takeaway. This is the right shape for a complexity regression guard, and it is measurably more robust than its neighbours: during this review the three absolute-budget tests in the same suite failed purely from concurrent xcodebuild load — the SAME test both passing and failing across four parallel workers — while the ratio test passed in all four. Measured ratio 3.85–4.11 (-O) and 3.87–3.95 (-Onone).
Rationale. The comment cites T-1655 (a complexity bug that an absolute budget would have missed) and T-1541 (this repo's timing-flake history) as the reasons for choosing a ratio. One caveat worth a comment: an n^1.5 regression lands at exactly 4^1.5 = 8.0, on the boundary — the test discriminates linear from quadratic, not linear from mildly superlinear.

Sixteen behavioural fixtures, placed to fail for the right reason

prismTests/FootnotePreprocessorTests.swift

Why it matters. All 16 drive the public process() and assert extraction outcomes plus cleanedSource content; no private helper is touched. Each places the [^id] reference so a wrongly-extracted definition survives orphan filtering — otherwise a broken detector would yield 'no definition' for the trivial reason that an orphan was dropped, and the test would pass while the bug lived.

What to look at. prismTests/FootnotePreprocessorTests.swift:167-481

Takeaway. A regression fixture has to be checked for WHY it fails, not just that it fails — the orphan-filtering trap here would have produced a green suite over a live bug. Mutation-testing the fixture against the unfixed code is the cheap way to confirm.
Rationale. The comment blocks at lines 228-236 and 377-379 record the reasoning explicitly, including why the unclosed-tag fixtures deliberately keep no blank line before the definition (so the raw-HTML block genuinely still covers it, rather than pinning the T-1963(a) blank-line divergence).

CHANGELOG: one 273-word Fixed entry

CHANGELOG.md

Why it matters. This text is synced verbatim into the app's bundled release-notes.md and onboarding guide by the prism-release-prep skill, so factual errors and length both reach users.

What to look at. CHANGELOG.md:21

Takeaway. Release-note claims should be checked against the last RELEASED state, not against the branch's own first commit — otherwise an intra-PR regression gets advertised to users as a fix.
Open question. Rationale not stated by the author and not inferable from the diff.

Key decisions

Balance tags within the line rather than adopting CommonMark type-6 extent.

A CommonMark type-6 block ends at the first blank line, whether or not the tag was closed. The preprocessor instead ends a block at its closing tag and keeps its own notion of extent. This is deliberate and is the whole point of the ticket: a self-contained single-line element must not park the state machine. The cost is that the preprocessor and swift-markdown can disagree about where a block ends — two such disagreements are documented and tracked in T-1963, and this review found a third (see the findings table).

Return the first tag left open, in line order.

Documented in the doc comment and the PR body: “that is the block the following lines sit in”. This is also a behaviour change from origin/main, which returned the first match in blockTags array order — so <section><div> used to report div and now reports section. The new answer is strictly better for the subsequent closing check. No existing test pinned the old ordering.

Consume every letter-initial tag whole, not only recognised block tags.

scanTagRemainder is called at line 469, before the guard let tag at line 472. That ordering is load-bearing: it is why <span title="<div>">x</span> returns nil even though span is not a block tag. The doc comment at lines 422-424 says “Recognised tags are consumed whole”, which understates the guarantee.

Treat a self-closing block tag as opening no block.

Pinned by testSelfClosingBlockTagDoesNotOpenBlock. Worth recording that this departs from CommonMark, whose type-6 start condition explicitly accepts /> — so <div/> does open a type-6 block. The departure is consistent with the ticket's intent, but it is what makes finding #1 a defect rather than a design choice: misclassifying a non-self-closing tag as self-closing is not part of the intended departure.

(inferred — not stated by the author.)
Do not run the full make test-quick suite.

Stated in the PR body: make test-quick has a documented pre-existing crash cascade and its exit code is masked by the xcbeautify pipe. Targeted suites were run without the pipe so exit codes are real. This review followed the same approach and independently confirmed both platform builds and the two relevant test classes.

Keep the bugfix report in the PR body rather than specs/bugfixes/.

The PR body states this explicitly: “per the workflow constraints for this run”. Worth flagging because the convention is live — six recent bugfix commits each ship or update a specs/bugfixes/<name>/report.md — and a squash-merge keeps the material on GitHub but not in the repository.

Review findings

SeverityAreaFindingResolution
majorFootnotePreprocessor.swift:544-547 — scanTagRemainderA `/` inside an UNQUOTED attribute value is misread as the self-closing marker, so a real HTML block never opens. Verified empirically against the real source: `<div title=x/>` and `<div data-url=https://example.com/>` both return nil; origin/main returns "div". Per the HTML5 attribute-value-unquoted state the `/` is part of the value and the tag is not self-closing, and CommonMark type-6 opens a block. This is a regression relative to the base commit and to both reference parsers, in the same family as the round-2 (quoted values) and round-3 (quote state machine) fixes, and it is not mentioned in any doc comment, the CHANGELOG, or T-1963. Consequence: a footnote definition on a following line inside such a div is now extracted and stripped where main left it alone. Found independently by two review agents; reproduced directly. Suggested fix: track an unquoted-value state and suppress the self-closing marker inside it (`isSelfClosing = lastNonSpace == "/" && !inUnquotedValue`), plus one fixture — or scope it out explicitly in the doc comment alongside the T-1963 gaps.Reported, not fixed — this is a production-code change and needs an author decision (fix versus documented scope-out).
majorCHANGELOG.md:21 and FootnotePreprocessorPerformanceTests.swift:154-158The CHANGELOG tells users "Balancing the tags on a line also no longer costs time in proportion to the square of the tag count, so a line packed with unmatched closing tags — reachable from any document, including a remote one opened by URL — no longer stalls." Verified false against the base: `git show origin/main:prism/Services/FootnotePreprocessor.swift` shows the old detector was 11 case-insensitive `range(of:)` searches with NO open-tag stack — linear. The quadratic `openTags.lastIndex(of:)` + `remove(at:)` form was introduced by this PR's own first commit a89a11f (line 439) and fixed by its second, 5faa6ea. No released version ever stalled, so the entry advertises a user-facing fix that does not exist between releases — and this text is synced verbatim into the app's bundled release notes by prism-release-prep. The same claim is repeated as permanent fact in the new test's doc comment ("The balancing used to remove the last matching entry by scanning that stack ... reachable from any opened document, including a remote one via Open URL"), where it will read to any future maintainer as a shipped bug.Reported, not fixed. Suggested: delete the clause (or reduce to "balancing is linear in tag count"), and reword the test comment to say the quadratic form existed only in an earlier revision of this same change.
minorFootnotePreprocessor.swift:426-435 + CHANGELOG.md:21 + 5 fixturesThis change creates a THIRD divergence from swift-markdown, in the opposite direction from T-1963(a), and documents none of it. Because a balanced line now ends the block at its closing tag rather than at the next blank line, a definition on the line IMMEDIATELY after a balanced one-line HTML block is extracted, where swift-markdown places that line inside the type-6 HTMLBlock. The CHANGELOG's closing sentence therefore says "Two ways ... can still disagree" when there are three. Five of the new fixtures pin exactly this shape (testOneLineBlockWhileReprocessingDefinitionLine, testLiteralQuoteAfterAttributeValueDoesNotSwallowTag, testLiteralSingleQuoteAfterAttributeValueDoesNotSwallowTag, testStrayQuoteInAttributeValueDoesNotSwallowClosingTag, testSpacedEqualsStillSkipsQuotedAttributeValue). Note these fixtures would still discriminate with a blank line inserted before the definition — a regression would open the block, and the preprocessor's continuation runs past blank lines, so the definition would still be swallowed — so the divergence does not have to be pinned to keep the tests meaningful.Reported, not fixed — touches both the CHANGELOG (editorial) and test fixtures (test logic), so it needs one coherent author pass.
minorFootnotePreprocessor.swift:570-572 — detectHTMLBlockClosingThe exit side of the state machine is now conspicuously weaker than the entry side. detectHTMLBlockOpening is nesting-aware and quote-aware; .inHTMLBlock termination is still a bare `line.range(of: "</tag>")` with no depth and no quote awareness. Verified consequences: an inner `</div>` on a continuation line ends the block a level early, and `<p title="</div>">x</p>` ends it from inside a quoted attribute — both resuming extraction while still inside raw HTML, i.e. the mirror image of T-1877. Both are pre-existing and NOT regressions, but the same input is now handled rigorously on one line and naively across two. Separately, this is the file's largest hotspot for HTML-heavy documents (measured 1401 ms over a ~12 MB <table> block, ~10x anything the opening detector costs) because it runs one case-insensitive range(of:) per line for the whole block.Reported. Suggested: fold both shapes into T-1963 so HTML-block extent is scoped as one job, rather than fixing here.
minorspecs/bugfixes/ — missing reportNo specs/bugfixes/<name>/report.md for this bug. The convention has not lapsed: f540cda (T-1775), e9d2fed (T-1719/1662/1893), 6788b9c (T-1680), 22fbdd9 (T-1641), 6b69588 (T-1504) and b1062d8 (T-1556) each ship or update one, and the fix-bug skill mandates it. The PR body carries the report content and states the omission was deliberate for this batch run, so this is a process decision rather than an oversight — but a squash-merge keeps that material on GitHub only. The in-repo report is the natural home for what would otherwise be lost: the HTML5 before-attribute-value rationale, the 20,000-case differential fuzz figures (325 / 399), the withdrawn round-1 "only two divergences" claim, and the T-1963 scope-out.Reported for an author/orchestrator decision — not a code defect.
minorprismTests/FootnotePreprocessorTests.swift — coverage gapsThree gaps in otherwise strong coverage. (a) matchBlockTag's boundary check has NO behavioural coverage — no fixture uses a tag whose name merely starts with a block tag name. Verified correct today (<divider>, <pretend>, <tablet> all return nil), but the check was rewritten from range(of:"<tag>") + first-char test to prefix(while:) + boundary set, so a regression like <pretend> opening a `pre` block — the exact T-1877 symptom — would ship silently. (b) testVoidElementsDoNotSuppressFootnotes is vacuous: neither hr nor br is in blockTags, so it passes on origin/main too and guards blockTags membership rather than anything this change introduced. (c) No case-insensitivity fixture, although matchBlockTag replaced `options: .caseInsensitive` with an explicit lowercased() comparison. Also untested: the documented "first tag in line order wins" rule with two simultaneously live slots (<section><div>), and a non-block tag carrying a block tag name in an attribute (<span title="<div>">), which the pre-guard scanTagRemainder call exists specifically to make safe.Reported, not fixed — adding or replacing fixtures is test-logic work.
minorFootnotePreprocessor.swift:422-424 and 509-510 — doc comments contradict the codeTwo statements in the new doc comments are wrong. (a) "**Recognised** tags are consumed whole by scanTagRemainder" — scanTagRemainder is called at line 469, before the `guard let tag` at 472, so EVERY letter-initial tag is consumed whole. That understates the guarantee in a way that matters, because it is exactly why <span title="<div>">x</span> is safe. (b) "the returned index is always past `index`" — false for the early return at lines 540-542, which returns cursor == index when line[index] == "<". The real invariant is that the CALLER's loop advances because the caller passes nameStart > index, and this is the sentence the termination argument rests on.Reported, not applied — deliberately left so the author makes one coherent pass over the comments alongside findings #1 and #3.
minorCHANGELOG.md:21 — length, register and attributionThe entry is 273 words / 1744 characters. The longest neighbour in the same Fixed section is 170 words; the median is ~85. Precedent for known-remaining gaps is a single clause ("that is tracked separately"), not two named gaps with syntax examples. The register is also more internal than its neighbours ("Balancing the tags on a line", "phantom block", "the real parser"). Separately, the `<div title="a"b">` clause is attached to the wrong cause: on main that line broke footnotes because `<div` sits at column 0 — the plain T-1877 root cause — not because of the quote, and the quote-driven phantom block existed only between a89a11f and 5faa6ea. "deliberately-escaped quote" also contradicts the code's own doc comment, which correctly notes HTML attribute values have no escaping. Nit: the entry uses both "preprocessing" and "pre-processing" in one paragraph.Reported, not applied — folds into the same CHANGELOG rewrite as findings #2 and #3.
nitFootnotePreprocessor.swift:412-440, 499-510 — doc comments narrate the PRRoughly 50 of the ~85 added production lines are doc comment, and several sentences narrate this PR's own review iterations ("Treating every quote as a delimiter instead made ... reviving T-1877's swallow-every-following-footnote symptom (T-1877 review)"). 29 lines of doc precede a 36-line body. The durable rationale — HTML5 before-attribute-value semantics, the T-1963 scope-out, the O(tags) note — earns its place; the iteration history belongs in the missing bugfix report and will go stale as soon as T-1963 lands.Reported.
nitFootnotePreprocessor.swift:516, 484-486 — naming and shadowing`lastNonSpace` is set on any non-whitespace (not just non-space), is assigned the quote character after a quoted value purely to mean "not a slash", and is only ever compared against `/`. A Bool named for its meaning (sawTrailingSolidus) is clearer and drops the quote assignment. Separately, `for slot in openSlots { if let slot { return slot } }` shadows a String? loop variable with a String; `openSlots.compactMap { $0 }.first` or a distinct binding name reads better.Reported.
nitFootnotePreprocessor.swift:556-568, 446 — measured headroommatchBlockTag's `prefix(while: isLetter || isNumber).lowercased()` is unbounded although the longest block tag is 10 characters: `<` plus a 200k-letter run costs a measured 11.48 ms where a variant bounded at 11 costs 0.6 microseconds. On the normal path bounding is only ~9% faster, so the value is the defensive cap. Separately, `openSlots` grows monotonically — a slot index is never reused even when every open is matched — so a 10 MB balanced line allocates ~833k String? slots (~13 MB); tracking a live count and clearing both structures when it reaches zero bounds memory by nesting depth. Explicitly do NOT change blockTags to a Set: measured Array.contains 24.72 ms vs Set.contains 25.59 ms over 100k iterations. And do not reach for unicodeScalars — the cost is the Unicode property lookup in Character.isWhitespace/.isLetter (~58 ns each), so only a UTF-8 byte port pays off (7–49x, cross-checked semantically identical on 21 cases).Reported. None of this is required for correctness; the change is already a net performance win over origin/main.
nitprismTests/FootnotePreprocessorPerformanceTests.swift — test hygiene(a) The three warm-up iterations run only on smallDocument, so any first-touch cost specific to the 120 KB line lands on the large measurement and biases the ratio toward the 8x ceiling — i.e. toward the failure direction. One warm-up run on largeDocument removes that. (b) The suite is not `.serialized`, although this repo uses .serialized in 10+ suites for exactly this class of flake and the three absolute-budget tests beside it failed from concurrent load during this review. (c) Naming deviates from its own file: siblings are preprocess500KBWith50Footnotes / preprocessingScalesLinearly with no `test` prefix. (d) The stress shape uses uniformly short tags, so it cannot detect a re-introduced overlapping-scan regression between scanTagRemainder and matchBlockTag — the thing most likely to break if either helper is touched; a long-interior shape would guard it (measured linear today, so it would pass).Reported, not fixed — test-logic changes.
nitPR #323 body — stale helper nameThe Resolution section still says "Two helpers were added: matchBlockTag(in:at:) ... and isSelfClosingTag(in:from:)". isSelfClosingTag no longer exists — it was replaced by scanTagRemainder in round 2, which the Affected Files table further down states correctly.Reported. Not edited, so the author can correct the body alongside the CHANGELOG.
nitFootnotePreprocessor.swift:442 — pre-existing, in the rewritten functiontrimmingCharacters(in: .whitespaces) accepts any indentation, so a four-space-indented `<div>` — an indented code block in CommonMark, no HTML block at all — opens one. Verified: with ` <div>` the following definition is not extracted. detectCodeFenceOpening twelve lines above does enforce spacesCount <= 3. Unchanged by this PR, but it is inside the function being rewritten and the fix is one line.Reported. Natural companion to the T-1963 extent work.

Per-file diffs

Click to expand.

prism/Services/FootnotePreprocessor.swift Modified +146 / -6
diff --git a/prism/Services/FootnotePreprocessor.swift b/prism/Services/FootnotePreprocessor.swiftindex 8117de7..2c50e60 100644--- a/prism/Services/FootnotePreprocessor.swift+++ b/prism/Services/FootnotePreprocessor.swift@@ -409,24 +409,164 @@ enum FootnotePreprocessor: Sendable {     nonisolated private static let blockTags = ["div", "details", "section", "article", "aside", "nav",                                                 "header", "footer", "table", "pre", "blockquote"] +    /// Returns the block-level tag that this line leaves open, or `nil` when the line+    /// opens no HTML block at all.+    ///+    /// The line is scanned once, left to right, tracking opening and closing block tags so+    /// a block opened and closed on the same line (`<div>Text</div>`) does not put the+    /// parser into `.inHTMLBlock` state and swallow the footnote definitions that follow+    /// (T-1877). Self-closing tags (`<div/>`) never open a block, and closing tags with+    /// no matching opening on the line are ignored. When several tags remain unclosed the+    /// first one (in line order) wins, since that is the block the following lines sit in.+    ///+    /// Recognised tags are consumed whole by `scanTagRemainder`, so a block tag name that+    /// only appears inside a quoted attribute value (`<table title="<div>">Content</table>`)+    /// is not mistaken for real markup and left spuriously open.+    ///+    /// Known gap, deliberately out of scope here: HTML comments get no special treatment, so+    /// a block tag name that only appears inside one (`<!-- comment with <div> -->`) is still+    /// read as real markup and leaves a block spuriously open — swallowing every following+    /// footnote definition, the same user-visible failure as T-1877 itself. `swift-markdown`+    /// disagrees: it parses that line as a CommonMark **type-2** comment block, which ends at+    /// the `-->` rather than at a blank line, so the next line is an ordinary paragraph.+    /// Closing this needs real comment-span recognition (`<!--` … `-->`, possibly across+    /// lines) rather than a patch to the tag scan, so it is tracked in T-1963 alongside the+    /// blank-line termination divergence — both are the same root cause: this preprocessor+    /// keeps its own notion of HTML-block extent, which can disagree with the real parser.+    ///+    /// Balancing costs O(tags) rather than O(tags²): each open reserves a slot in+    /// `openSlots` and records that slot under its name, and each close pops its own name's+    /// most recent slot, so a close with no matching open costs nothing instead of scanning+    /// the whole open stack (the shape T-1655 fixed in the raw-HTML image scan).     nonisolated private static func detectHTMLBlockOpening(_ line: String) -> String? {         let trimmed = line.trimmingCharacters(in: .whitespaces)         guard trimmed.hasPrefix("<") else { return nil } -        for tag in blockTags {-            guard let range = trimmed.range(of: "<\(tag)", options: .caseInsensitive) else {+        // Open tags in line order; a slot is cleared when its closing tag is seen.+        var openSlots: [String?] = []+        var slotsByTag: [String: [Int]] = [:]+        var index = trimmed.startIndex++        while index < trimmed.endIndex {+            guard trimmed[index] == "<" else {+                index = trimmed.index(after: index)                 continue             }-            // Ensure tag name ends at a boundary (>, space, tab, /, or end of string)-            let afterTag = trimmed[range.upperBound...]-            if afterTag.isEmpty || ">/ \t".contains(afterTag.first!) {-                return tag++            var nameStart = trimmed.index(after: index)+            let isClosing = nameStart < trimmed.endIndex && trimmed[nameStart] == "/"+            if isClosing {+                nameStart = trimmed.index(after: nameStart)+            }++            // A `<` not followed by a tag name is literal text (`3 < 4`), not markup.+            guard nameStart < trimmed.endIndex, trimmed[nameStart].isLetter else {+                index = trimmed.index(after: index)+                continue+            }++            let tag = matchBlockTag(in: trimmed, at: nameStart)+            let scan = scanTagRemainder(in: trimmed, from: nameStart)+            index = scan.end++            guard let tag else { continue }++            if isClosing {+                if let slot = slotsByTag[tag]?.popLast() {+                    openSlots[slot] = nil+                }+            } else if !scan.isSelfClosing {+                slotsByTag[tag, default: []].append(openSlots.count)+                openSlots.append(tag)             }         } +        for slot in openSlots {+            if let slot { return slot }+        }         return nil     } +    /// Consumes the HTML tag whose name starts at `index` (just past its `<` or `</`), and+    /// reports where the tag ends plus whether it was self-closing (`<div/>`).+    ///+    /// A quoted attribute value is skipped as a unit, so a `<` or `>` inside `title="<div>"`+    /// is never read as markup. An unterminated quote consumes the rest of the line, leaving+    /// the tag open — the same treatment an unterminated tag already gets. A `<` found+    /// outside a value ends the scan at that point rather than swallowing it, so a stray `<`+    /// in the text (`<div>a <b</div>`) cannot hide the closing tag that follows it.+    ///+    /// Quote handling follows the HTML5 tokenizer rather than inventing escapes, because HTML+    /// attribute values have none: a quote only opens a value in the *before attribute value*+    /// state — immediately after an `=`, whitespace allowed — and a double-quoted value then+    /// ends at the very next `"` with no escape processing (likewise `'`). So `title="\""` is+    /// the value `\` followed by a bogus attribute named `"`, and the tag still ends at its+    /// `>`. Treating every quote as a delimiter instead made any odd number of literal quotes+    /// of the matching type inside a value — a deliberate `\"`, or just a careless straight+    /// quote in `title="a"b"` — re-open a value that then ran past the real `>` and closing+    /// tag, reviving T-1877's swallow-every-following-footnote symptom (T-1877 review).+    ///+    /// Each character is visited at most once and the returned index is always past `index`,+    /// so the caller's loop makes progress and stays linear in line length.+    nonisolated private static func scanTagRemainder(+        in line: String,+        from index: String.Index+    ) -> (end: String.Index, isSelfClosing: Bool) {+        var cursor = index+        var lastNonSpace: Character?+        // True in the HTML5 "before attribute value" state: an `=` has been seen and only+        // whitespace since, so the next quote opens a quoted value.+        var expectingValue = false++        while cursor < line.endIndex {+            let character = line[cursor]++            if expectingValue, character == "\"" || character == "'" {+                cursor = line.index(after: cursor)+                while cursor < line.endIndex, line[cursor] != character {+                    cursor = line.index(after: cursor)+                }+                guard cursor < line.endIndex else { return (line.endIndex, false) }+                lastNonSpace = character+                expectingValue = false+                cursor = line.index(after: cursor)+                continue+            }++            if character == ">" {+                return (line.index(after: cursor), lastNonSpace == "/")+            }++            if character == "<" {+                return (cursor, false)+            }++            if !character.isWhitespace {+                lastNonSpace = character+                expectingValue = character == "="+            }+            cursor = line.index(after: cursor)+        }++        return (line.endIndex, false)+    }++    /// Matches a known block tag name starting at `index`, requiring a tag-name boundary+    /// (`>`, `/`, whitespace, or end of line) immediately after it.+    nonisolated private static func matchBlockTag(+        in line: String,+        at index: String.Index+    ) -> String? {+        guard index < line.endIndex else { return nil }+        let remainder = line[index...]++        let name = remainder.prefix(while: { $0.isLetter || $0.isNumber }).lowercased()+        guard blockTags.contains(name) else { return nil }++        guard let boundary = remainder.dropFirst(name.count).first else { return name }+        return ">/ \t".contains(boundary) ? name : nil+    }+     nonisolated private static func detectHTMLBlockClosing(_ line: String, tag: String) -> Bool {         line.range(of: "</\(tag)>", options: .caseInsensitive) != nil     }
prismTests/FootnotePreprocessorTests.swift Modified +315 / -0
diff --git a/prismTests/FootnotePreprocessorTests.swift b/prismTests/FootnotePreprocessorTests.swiftindex 6e2ebd9..3333eb2 100644--- a/prismTests/FootnotePreprocessorTests.swift+++ b/prismTests/FootnotePreprocessorTests.swift@@ -164,6 +164,321 @@ struct FootnotePreprocessorTests {         #expect(result.footnoteData.definition(for: "inside") == nil)     } +    // MARK: - Single-Line HTML Blocks (T-1877)++    @Test("One-line div block does not suppress following footnotes")+    func testOneLineDivDoesNotSuppressFootnotes() {+        let source = """+        <div>Text</div>++        Ordinary content Text[^1].++        [^1]: Note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "1")?.content == "Note.")+        #expect(!result.cleanedSource.contains("[^1]: Note."))+        #expect(result.cleanedSource.contains("<div>Text</div>"))+    }++    @Test("One-line details block does not suppress following footnotes")+    func testOneLineDetailsDoesNotSuppressFootnotes() {+        let source = """+        <details><summary>More</summary>Text</details>++        Ordinary content Text[^1].++        [^1]: Note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "1")?.content == "Note.")+        #expect(result.cleanedSource.contains("<details><summary>More</summary>Text</details>"))+    }++    @Test("Nested tags closed on the same line do not suppress following footnotes")+    func testNestedSameLineTagsDoNotSuppressFootnotes() {+        let source = """+        <div><span>x</span></div>++        Text[^n].++        [^n]: Nested note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "n")?.content == "Nested note.")+    }++    @Test("Nested same-name tags closed on the same line do not suppress following footnotes")+    func testNestedSameNameTagsDoNotSuppressFootnotes() {+        let source = """+        <div><div>x</div></div>++        Text[^n].++        [^n]: Nested note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "n")?.content == "Nested note.")+    }++    // The two fixtures below keep the definition line directly after the opening line,+    // with no blank line in between, so the raw-HTML block genuinely still covers it: a+    // CommonMark type-6 block runs from its opening line to the first blank line, and+    // `swift-markdown` parses these inputs with the `[^inside]` line inside the `HTMLBlock`.+    // Separating them with a blank line would instead pin a divergence — the preprocessor's+    // `.inHTMLBlock` continuation runs past blank lines while the real parser stops at them,+    // so it would keep suppressing a definition `swift-markdown` reads as a plain paragraph.+    // That divergence is pre-existing, out of scope for T-1877, and tracked in T-1963.+    // Each fixture also references `[^inside]` from outside the block, so a definition that+    // was wrongly extracted would survive orphan filtering and fail the assertions.++    @Test("Unclosed tag after a same-line closed tag still opens an HTML block")+    func testUnclosedTagAfterClosedTagOpensBlock() {+        let source = """+        Intro[^out] and a dangling ref[^inside].++        <div>x</div><section>+        [^inside]: Inside the still-open section.+        </section>++        [^out]: Outside.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "out")?.content == "Outside.")+        #expect(result.footnoteData.definition(for: "inside") == nil)+        #expect(result.cleanedSource.contains("[^inside]: Inside the still-open section."))+    }++    @Test("Unbalanced closing tag on the same line leaves the block open")+    func testMismatchedTagsLeaveBlockOpen() {+        let source = """+        Intro[^out] and a dangling ref[^inside].++        <div>Text</section>+        [^inside]: Still inside the unclosed div.+        </div>++        [^out]: Outside.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "out")?.content == "Outside.")+        #expect(result.footnoteData.definition(for: "inside") == nil)+        #expect(result.cleanedSource.contains("[^inside]: Still inside the unclosed div."))+    }++    @Test("Self-closing block tag does not open an HTML block")+    func testSelfClosingBlockTagDoesNotOpenBlock() {+        let source = """+        <div/>++        Text[^s].++        [^s]: Self-closed.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "s")?.content == "Self-closed.")+    }++    @Test("Void HTML elements do not suppress following footnotes")+    func testVoidElementsDoNotSuppressFootnotes() {+        let source = """+        <hr/>++        <br>++        Text[^v].++        [^v]: Void note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "v")?.content == "Void note.")+    }++    @Test("One-line HTML block after a definition does not suppress later definitions")+    func testOneLineBlockWhileReprocessingDefinitionLine() {+        let source = """+        Text[^a] and more[^b].++        [^a]: Definition A.+        <div>Inline block</div>+        [^b]: Definition B.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "a")?.content == "Definition A.")+        #expect(result.footnoteData.definition(for: "b")?.content == "Definition B.")+        #expect(result.cleanedSource.contains("<div>Inline block</div>"))+    }++    // MARK: - Block Tag Names Inside Attribute Values (T-1877 review)++    @Test("Block tag name inside a double-quoted attribute value does not open a block")+    func testBlockTagInDoubleQuotedAttributeDoesNotOpenBlock() {+        // `swift-markdown` parses this line as one self-contained HTMLBlock followed by+        // ordinary paragraphs, so nothing after it may be suppressed.+        let source = """+        <table title="<div>">Content</table>++        Ordinary content Text[^q].++        [^q]: Quoted attribute note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "q")?.content == "Quoted attribute note.")+        #expect(!result.cleanedSource.contains("[^q]: Quoted attribute note."))+        #expect(result.cleanedSource.contains("<table title=\"<div>\">Content</table>"))+    }++    @Test("Block tag name inside a single-quoted attribute value does not open a block")+    func testBlockTagInSingleQuotedAttributeDoesNotOpenBlock() {+        let source = """+        <div title='<section>'>Text</div>++        Ordinary content Text[^q].++        [^q]: Single-quoted attribute note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "q")?.content == "Single-quoted attribute note.")+        #expect(result.cleanedSource.contains("<div title='<section>'>Text</div>"))+    }++    @Test("Unterminated attribute quote leaves the block open")+    func testUnterminatedAttributeQuoteLeavesBlockOpen() {+        // With the quote never closed the tag never terminates, so the div stays open --+        // and a type-6 block opens on `<div` regardless, which is what `swift-markdown`+        // does here too (the `[^inside]` line lands inside its HTMLBlock).+        let source = """+        Intro[^out] and a dangling ref[^inside].++        <div title="oops>+        [^inside]: Inside the block.+        </div>++        [^out]: Outside.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "out")?.content == "Outside.")+        #expect(result.footnoteData.definition(for: "inside") == nil)+        #expect(result.cleanedSource.contains("[^inside]: Inside the block."))+    }++    // Each of the four tests below puts the reference *before* the HTML line and the+    // definition on the line directly after it, so a wrongly-opened block swallows the+    // definition while the reference survives orphan filtering and the assertion fails.++    @Test("A literal quote after a closed attribute value does not extend the value")+    func testLiteralQuoteAfterAttributeValueDoesNotSwallowTag() {+        // HTML has no escaping inside attribute values: per the HTML5 tokenizer a+        // double-quoted value ends at the very next `"`, with no escape processing. So+        // `title="\""` is the value `\` followed by a bogus attribute named `"`, and the tag+        // still ends at the `>` -- leaving the line balanced (verified against both the+        // HTML5 tokenizer and `swift-markdown`). Reading that trailing `"` as a *fresh*+        // value delimiter consumed the rest of the line, including the real `</div>`, and+        // reopened T-1877's swallow-every-following-footnote symptom through the+        // attribute-value path.+        let source = """+        Intro[^q] first.++        <div title="\\"">Content</div>+        [^q]: Odd double quote note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "q")?.content == "Odd double quote note.")+        #expect(!result.cleanedSource.contains("[^q]: Odd double quote note."))+        #expect(result.cleanedSource.contains("<div title=\"\\\"\">Content</div>"))+    }++    @Test("A literal quote after a closed single-quoted attribute value does not extend it")+    func testLiteralSingleQuoteAfterAttributeValueDoesNotSwallowTag() {+        let source = """+        Intro[^q] first.++        <div title='\\''>Content</div>+        [^q]: Odd single quote note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "q")?.content == "Odd single quote note.")+        #expect(!result.cleanedSource.contains("[^q]: Odd single quote note."))+        #expect(result.cleanedSource.contains("<div title='\\''>Content</div>"))+    }++    @Test("A stray quote in an attribute value does not swallow the closing tag")+    func testStrayQuoteInAttributeValueDoesNotSwallowClosingTag() {+        // The same shape with no pretence of escaping -- a careless straight quote is enough.+        // HTML5 reads `title="a"b"` as value `a` plus a bogus attribute named `b"`.+        let source = """+        Intro[^q] first.++        <div title="a"b">Content</div>+        [^q]: Stray quote note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "q")?.content == "Stray quote note.")+        #expect(!result.cleanedSource.contains("[^q]: Stray quote note."))+    }++    @Test("Whitespace around the attribute equals still delimits the quoted value")+    func testSpacedEqualsStillSkipsQuotedAttributeValue() {+        // The HTML5 before-attribute-value state skips whitespace after `=`, so the quote+        // still opens a value here and the embedded `<div>` stays inert.+        let source = """+        Intro[^q] first.++        <table title = "<div>">Content</table>+        [^q]: Spaced equals note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "q")?.content == "Spaced equals note.")+        #expect(!result.cleanedSource.contains("[^q]: Spaced equals note."))+    }++    @Test("A stray less-than in text does not hide the closing tag after it")+    func testStrayLessThanDoesNotHideClosingTag() {+        let source = """+        <div>a <b</div>++        Ordinary content Text[^s].++        [^s]: Stray marker note.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "s")?.content == "Stray marker note.")+    }++    @Test("Definition text inside a one-line HTML block is not extracted")+    func testDefinitionInsideOneLineBlockIsNotExtracted() {+        let source = """+        Text[^real].++        <div>[^fake]: Not a definition.</div>++        [^real]: Real definition.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definitions.count == 1)+        #expect(result.footnoteData.definition(for: "real") != nil)+        #expect(result.footnoteData.definition(for: "fake") == nil)+        #expect(result.cleanedSource.contains("<div>[^fake]: Not a definition.</div>"))+    }+     // MARK: - Reference Ordering      @Test("First appearance determines display number")
prismTests/FootnotePreprocessorPerformanceTests.swift Modified +76 / -0
diff --git a/prismTests/FootnotePreprocessorPerformanceTests.swift b/prismTests/FootnotePreprocessorPerformanceTests.swiftindex 14c8652..beaf161 100644--- a/prismTests/FootnotePreprocessorPerformanceTests.swift+++ b/prismTests/FootnotePreprocessorPerformanceTests.swift@@ -146,6 +146,82 @@ struct FootnotePreprocessorPerformanceTests {         }     } +    // MARK: - Same-Line HTML Tag Balancing Growth (T-1877 review)++    /// Builds a document whose single raw-HTML line holds `pairs` opening `<div>` tags+    /// followed by `pairs` closing `</section>` tags.+    ///+    /// This is the worst case for same-line tag balancing: every close names a tag that+    /// was never opened, so a search of the open-tag stack cannot short-circuit. The+    /// balancing used to remove the last matching entry by scanning that stack, which made+    /// the line quadratic in tag count and was reachable from any opened document,+    /// including a remote one via Open URL.+    /// The reference and its definition sit *ahead* of the stress line on purpose: the+    /// line's unmatched closes leave a block open by design, so a definition placed after+    /// it would correctly be left alone and the correctness check below would not hold.+    private func htmlTagStressDocument(pairs: Int) -> String {+        let line = String(repeating: "<div>", count: pairs)+            + String(repeating: "</section>", count: pairs)+        return "Text[^p].\n\n[^p]: Note.\n\n" + line + "\n"+    }++    private func fastestElapsed(runs: Int, of body: () -> Void) -> Duration {+        (0..<runs).map { _ in+            let start = ContinuousClock.now+            body()+            return ContinuousClock.now - start+        }.min()!+    }++    private func milliseconds(_ duration: Duration) -> Double {+        Double(duration.components.seconds) * 1000.0+            + Double(duration.components.attoseconds) / 1_000_000_000_000_000.0+    }++    @Test("Same-line HTML tag balancing does not grow quadratically with tag count")+    func testHTMLTagBalancingDoesNotGrowQuadratically() {+        // Deliberately a growth-ratio assertion, not an absolute budget: one budget at one+        // input size cannot distinguish a linear scan from a quadratic one (the naming+        // lesson from T-1655). Quadrupling the tag count costs ~4x when the scan is linear+        // and ~16x when it is quadratic, so the 8x ceiling sits with 2x headroom above+        // linear and 2x below quadratic — wide enough for the timing flakiness this repo+        // has seen (T-1541). Measured pre-fix on a release build, quadrupling cost ~15x.+        let smallDocument = htmlTagStressDocument(pairs: 2_000)+        let largeDocument = htmlTagStressDocument(pairs: 8_000)++        // Warm up so first-call costs do not land on the smaller measurement and inflate+        // the ratio; the growth signal is what matters, not the absolute numbers.+        for _ in 0..<3 {+            _ = FootnotePreprocessor.process(smallDocument)+        }++        let small = fastestElapsed(runs: 5) { _ = FootnotePreprocessor.process(smallDocument) }+        let large = fastestElapsed(runs: 5) { _ = FootnotePreprocessor.process(largeDocument) }++        let smallMs = milliseconds(small)+        let largeMs = milliseconds(large)+        let ratio = largeMs / smallMs+        let ratioText = String(format: "%.2f", ratio)++        print("HTML tag balancing — 2,000 pairs: \(small), 8,000 pairs: \(large), ratio: \(ratioText)")++        // Correctness alongside the timing: the stress line must not derail extraction of+        // the definition ahead of it.+        #expect(+            FootnotePreprocessor.process(largeDocument).footnoteData.definition(for: "p")?.content+                == "Note."+        )++        #expect(+            smallMs > 0,+            "Baseline measurement must be resolvable to make the ratio meaningful"+        )+        #expect(+            ratio < 8.0,+            "Quadrupling the tag count should cost roughly 4x, not ~16x — got \(ratioText)x (\(small) -> \(large))"+        )+    }+     @Test("Pre-processing is consistent across iterations")     func preprocessingConsistency() {         let content = generateContent(targetBytes: 250 * 1024, footnoteCount: 25)
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 0d8d7a7..63ef5c5 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Footnotes keep working after a one-line block of raw HTML (T-1877). Writing something like `<div>Text</div>` or `<details><summary>More</summary>Text</details>` on a single line silently switched off every footnote from that point on: references stayed as plain `[^1]` text instead of becoming badges, no popover was available, and the definitions themselves showed up as ordinary paragraphs. Footnote preprocessing was waiting for a closing tag that had already gone past on the same line, so it treated the rest of the document as raw HTML. Opening and closing tags are now balanced within the line, including nesting (`<div><div>x</div></div>`) and self-closing forms (`<div/>`). A tag genuinely left open — `<div>x</div><section>`, or a mismatched `<div>Text</section>` — still starts an HTML block, so definitions written inside raw HTML are still left alone. A tag name that only appears inside a quoted attribute value (`<table title="<div>">Content</table>`) no longer counts as real markup; it used to leave a phantom block open and switch off the following footnotes in exactly the same way, and so did a stray or deliberately-escaped quote inside such a value (`<div title="a"b">`). Balancing the tags on a line also no longer costs time in proportion to the square of the tag count, so a line packed with unmatched closing tags — reachable from any document, including a remote one opened by URL — no longer stalls. Two ways footnote pre-processing can still disagree with the real parser about where a raw-HTML block ends are tracked separately: a blank line after an unclosed tag, and a block tag name written inside an HTML comment (`<!-- comment with <div> -->`), which still switches off the footnotes that follow it. - Changing the theme while viewing raw source no longer brings back the previous version of the document (T-1759). If the file changed on disk — or a URL document was refreshed — at the same moment the colours changed, the recolour worked from a snapshot taken before the reload and, finishing last, put the old text back on screen, where it stayed until the next reload or raw/rendered toggle. Recolouring now only applies while the lines it started from are still the ones being shown. - The system **Increase Contrast** accessibility setting applies to the document again (T-1829). Since the WebKit rendering cutover, turning it on changed the app's own interface but left the document body untouched: search highlights and footnote badges stayed translucent and tertiary text stayed low-contrast. The rendered document now uses the same higher-contrast search, footnote, and tertiary colours as the rest of the app, on every theme, and the deliberately-faded "add note" button beside each block is shown at full strength. It follows the setting live — toggling it while a document is open updates immediately, without a reload and without losing your reading position, and the styling survives a WebKit process recovery. Footnote popovers keep the standard palette. - Jumping to a specific place in a document you have read before now lands where you asked instead of at your saved reading position (T-1775). This covers every way you can ask: following a link to a heading, picking a table-of-contents entry, opening a note, and stepping to a search match. The requested target was queued while the document's HTML was being prepared in the background, and the saved position — restored a moment later — replaced it, so the page settled where you last stopped reading. An explicit target now takes precedence for that load. Reading position is otherwise untouched: an ordinary reopen, returning from raw source, and a reload after the file changes on disk or a URL refresh all still restore where you left off, even with a search active.

Things to double-check

Re-verify before pushing after any fix to finding #1.

The unquoted-value fix touches scanTagRemainder's core loop, which every fixture from rounds 1–3 exercises. Re-run FootnotePreprocessorTests and FootnotePreprocessorPropertyTests without the xcbeautify pipe so the exit code is real, and mutation-check the new fixture against the current code to confirm it actually goes red.

The absolute performance tests in this suite are not signal.

preprocess500KBWith50Footnotes and preprocessingScalesLinearly failed during this review. Both are < .milliseconds(100) wall-clock budgets, and both passed and failed within the same run across four parallel xcodebuild workers — load flakes, confirmed by that split alone. Neither can be affected by this change: their fixtures are prose and headings, so no line starts with < and the new scan is never entered past the early guard. The ratio test passed in all four workers.

CI green does not mean validated on this repo.

PR #323 is MERGEABLE / CLEAN with all four checks green — SwiftLint, Stylelint, File Checks, Per-locale test sweep. None of them builds the app or runs the unit suites. (GitHub also queues no checks at all while a PR is CONFLICTING, so a green wall on a conflicting PR means nothing ran.) The builds and tests reported here were run locally.

The 22 ImageDimension build warnings are pre-existing.

Both platform builds succeed but emit 22 main actor-isolated conformance of ImageDimension to Equatable warnings ("this is an error in the Swift 6 language mode"). They were reproduced at the same count on the base checkout, and they originate in files this branch does not touch (MarkdownBlock.swift, BlockHTMLEmitter.swift, HTMLSanitizer.swift, …). Not attributable here, but they do stand between the repo and the “zero warnings” pre-push gate.

T-1963 is about to become a rewrite, not an addition.

The CommonMark-correct extent model — a type-6 opening starts a block, the block ends at the first blank line — needs no same-line tag balancing at all, and closes both documented divergences plus the third one found here in a single stroke. Whoever picks up T-1963 will likely delete most of detectHTMLBlockOpening, scanTagRemainder, matchBlockTag and the new performance test. Worth noting on the ticket so that work is not planned as an extension of this machinery.

This scanner is now the fourth of its kind in the repo.

scanTagRemainder joins HTMLImageSourceRewriter.attributeValue (T-1655), DetailsTokenizer.checkOpenAttribute (T-206) and CodeFenceHelper.findMatchingDetailsClose as hand-rolled “scan an HTML tag while skipping quoted values” implementations. Reuse is genuinely not viable here (different index types, different output contracts, and the rewriter's semantics are pinned by differential fuzzing on an audited security path), but a cross-reference comment between scanTagRemainder and attributeValue would stop the two quote rules diverging silently. Unrelated find worth its own ticket: DetailsTokenizer.findClosingTag materialises and lowercases the entire remaining string per character — the same quadratic shape T-1655 and this PR both fixed, reachable from any <details> with a large body.