prism branch T-1968/bugfix-escaped-footnote-references commits 2 (ahead of merge-base 118ef1da) files 10 touched (4 production, 3 test, 3 docs) lines +718 / -23 targeted tests 136 / 136 passed lint + guards clean

Pre-push review: T-1968/bugfix-escaped-footnote-references

A backslash-escaped footnote reference (\[^1]) was counted as a live reference by the preprocessor and by both search scanners, skewing display numbers and search results. The fix centralises one escape predicate and applies it at four call sites, and makes the preprocessor's code-span skip escape-aware for the opening backtick. Round 3 of this review — the round-2 blocker is verified fixed.

At a glance

  • Round-2 blocker verified fixed. let scanSource = components.scanSource at MarkdownBlock.swift:1048. Index provenance re-checked at all five call sites — all clean.
  • The escape rule is provably equivalent to the renderer's. FootnoteReferenceScanner.scan walks left-to-right skipping two units per backslash; because a maximal backslash run is always entered at its first unit, run-parity gives the same classification. The doc comment's claim holds.
  • The escaped-backtick opener change matches CommonMark and regresses nothing. Only the opener is escape-aware — backslash escapes do not apply inside a code span, so the closing run is still matched raw. Covered in both directions by tests.
  • All four production call sites are covered by behaviour tests going through public entry points, never the predicate. The two private SearchStateFeeder scans are observed through the emitted payload and mutation-checked.
  • Comments were reworded honestly. The previous "can never disagree" / "stay in lockstep" overclaims are gone; the scanner inventory (six sites, four fixed, two pre-existing) is accurate against the source.
  • Minor: the newly-introduced residual is documented as narrower than it is. A\<!-- x -->[^1] collapsing to A\[^1] is attributed to HTMLCommentStripping.removing (the comments-ON branch), but the default comments-OFF branch uses HTMLImageParser.stripHTMLComments, which also excises with no separator.
  • Minor: one new test passes against the pre-fix code. testEscapedReferenceDoesNotSkewDisplayNumbers uses one id, so seen dedupes and the assertion holds either way. The real skew guard is its sibling.

Verdict

Ready to push

No blocking or major defects. The round-2 blocker — components.scanSource being a computed property read twice, so a String.Index from one String instance subscripted another — is genuinely fixed: it is hoisted to a let at MarkdownBlock.swift:1048 and was already hoisted at the sibling SearchStateFeeder.swift:253. I re-derived index provenance at all five call sites and found each one clean, including the Substring-vs-String case in the preprocessor (a Substring shares its base's index space, and for a prefixMatch the bound is the loop cursor by construction).

The second behaviour fix — the escaped code-span opener — is correct CommonMark and I traced five shapes by hand without finding a regression, including the subtle one where only the first backtick of a run is escaped (the run count correctly restarts at the second backtick). The loop still advances on every path. The three new SearchStateFeeder tests observe the two private scans through the payload and are mutation-checked, with the author correctly labelling the third as the premise rather than a detector.

Verification I ran: SwiftLint --strict clean (576 files, 0 violations); check-webkit-test-isolation.py, its 83 unit tests, check-workflow-triggers.py and verify-make-guards all pass; a targeted xcodebuild test over the five footnote/search suites finalised a result bundle at total=136 passed=136 failed=0. The branch also merges into the moved origin/main cleanly — no CHANGELOG conflict.

Six minor items remain, all documentation accuracy or test strength, none of them a code defect. This review was run strictly read-only, so nothing was fixed in the tree; the list below is a follow-up list, not a gate.

Review findings

8 raised · 0 fixed · 8 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 11

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

In Markdown you write a footnote reference as [^1], and the app turns it into a little numbered badge you can tap. If you want to show the syntax rather than use it, you put a backslash in front: \[^1]. That is called escaping, and it means "print this literally, do not treat it as a footnote".

The part of the app that renders the page already understood escaping. The part that numbers the footnotes did not. So an escaped \[^1] quietly took a number nobody could see, and every real footnote after it got bumped up by one. It could also keep a footnote alive at the bottom of the page that nothing genuinely pointed at.

Search had the same blind spot in two places, so an escaped reference sitting next to a real one to the same footnote could make that footnote's text count twice.

Why it matters

Wrong footnote numbers are the kind of bug a reader notices immediately and cannot work around — the badge says 3, the note at the bottom says 2. It also made two parts of the same app disagree about the same document, which is the sort of split that keeps producing new bugs until it is closed.

Key concepts

  • Escaping — a backslash makes the next character literal. Two backslashes are themselves an escaped backslash, so \\[^1] is a real reference again. Counting whether the run of backslashes in front is odd or even is what decides it.
  • One rule, one place — the fix adds a single shared function, FootnoteData.isEscaped, and calls it everywhere, instead of letting four different files each invent their own answer.
  • A second, related fix — backticks mark inline code, and an escaped backtick (\`) is not a code marker at all. The numbering pass was treating it as one, which swallowed the footnote reference that followed it.

Architecture

Prism parses footnotes in a preprocessor pass (FootnotePreprocessor) that runs before swift-markdown sees the source: it extracts [^id]: definitions, records the order references appear in, assigns display numbers, and drops definitions nothing references. Independently, the render path (FootnoteReferenceScanner + InlineHTMLRenderer) decides which occurrences become badges, and the search path (MarkdownBlock.combinedSearchableText plus two scans in SearchStateFeeder) decides which occurrences pull a footnote's body into searchable text and which map to a DOM badge.

Those are four separate scanners over the same [^id] syntax. FootnoteData.referencePattern had already been centralised; the escape rule had not. FootnoteReferenceScanner.scan gained escape-awareness in T-1716; nothing propagated it back.

Patterns

The fix is a shared predicate: FootnoteData.isEscaped(in:before:) counts the maximal run of backslashes immediately preceding an index and returns true on an odd length. It lives beside referencePattern — the file that already owns the shared syntax — and is named for the character, not for [^id], because the preprocessor asks the same question of a code-span-opening backtick.

Four call sites adopt it: the preprocessor's reference scan and its backtick skip, combinedSearchableText, and SearchStateFeeder's referencedFootnoteIdentifiers and badgeEligibility. The last two must stay aligned entry-for-entry, so the filter had to go into both or neither.

Trade-offs

The commit consciously did not reach for FootnoteReferenceScanner.scan itself even though it already answers a richer question, because at preprocessor time FootnoteData does not exist yet — the pass is what builds it. That leaves the three downstream sites free to have consumed the scanner instead; the decision log records the predicate choice but not that alternative.

Two of six scanners are knowingly left unescaped (FootnoteStripping.strip, DocumentReaderView.routeDocumentLink) and the branch says so plainly rather than claiming the scanners now agree. Four residuals are inventoried in the bugfix report, including one this change narrows into existence.

The predicate and its equivalence proof

isEscaped(in:before:) walks backwards from index over text, counting consecutive \, and returns count % 2 == 1. FootnoteReferenceScanner.scan instead walks forward over Array(source.utf16) doing index += 2 on 0x5C. These agree because the run considered by the backward walk is maximal: the character before it is not a backslash, so the forward walk cannot have jumped over the run's first unit, and from there both pair off left-to-right. The doc comment states this; it is correct.

Index provenance — the round-2 blocker

FootnoteScanComponents.scanSource is a computed property: ([baseWithoutMarkers] + surfacedCommentBodies).joined(separator: " "). Reading it twice produces two distinct String instances, and subscripting instance B with an index derived from instance A is a programmer error in Swift that happens to work only while the instances compare equal and share representation. MarkdownBlock.swift:1048 now hoists it; SearchStateFeeder.swift:253 already did. Verified at all five sites. The FootnotePreprocessor.swift:401 case (remaining = line[index...], predicate given line) is sound because a Substring shares its base's index space — though passing index directly, which prefixMatch guarantees to equal match.range.lowerBound, would remove the question a future reader has to re-derive.

The escaped-backtick opener

Per CommonMark, backslash escapes apply outside code spans only, so \` cannot open a span, while a backtick closing one is matched raw (`foo\`bar` is <code>foo\</code> plus literal bar`). The implementation is asymmetric in exactly that way. Traced shapes: `a\`b` unchanged; \`[^1]` now live (the fix); \\`[^1]` still opens a real span (even run) and is pinned by a test; \``[^1]`` restarts the run count at the second backtick, so it still diverges from cmark — but via the pre-existing "closing run length need not equal the opening run" residual, which the comment names. Every path advances index; no non-termination.

Alignment and residuals

referencedFootnoteIdentifiers applies escape-then-(caller's)-resolvable; badgeEligibility applies resolvable-in-a-where-then-escape. Both are conjunctions of independent predicates, so the surviving sets are identical whenever the two scan the same text — which is every block without an HTML comment. The pre-existing gap (comment-gated scanSource vs raw source) is unchanged and now documented at the call site.

Complexity

The backward walks cover pairwise-disjoint intervals — matches(of:) yields non-overlapping matches, and in the preprocessor the escaped-backtick branch leaves the backtick itself as a non-backslash wall the next walk cannot cross — so a line of N backslashes costs O(N) in total, not O(N²). Guarding before String(match.1) makes escaped matches allocation-free, and the escaped-backtick branch is strictly cheaper than the run-count + String(repeating:) + range(of:) path it replaces.

Important changes — detailed

FootnoteData: one shared backslash-parity escape predicate

prism/Models/FootnoteData.swift

Why it matters. This is the whole fix. Four scanners independently decided what counts as a live [^id]; only the renderer knew about escaping. The predicate closes that split at the source rather than at each symptom, and it is placed beside referencePattern, which the codebase had already centralised for the same reason.

What to look at. prism/Models/FootnoteData.swift:40-76 (isEscaped(in:before:))

Takeaway. When several components must agree on parsing one syntax, centralise the PREDICATE next to the pattern, not just the pattern. Naming it for the character (isEscaped) rather than the construct (isReferenceEscaped) is what let the preprocessor reuse it for a backtick instead of writing a second parity walk — the rename in the second commit is doing real work.
Rationale. Recorded as Quick Decision Q1/Q2 in specs/footnotes/decision_log.md and argued at length in the doc comment: run-parity is equivalent to FootnoteReferenceScanner.scan's left-to-right consumption for a Regex match, which only reports where a token starts. The doc comment is also explicit about what the predicate does NOT settle — the scanners agree on escaping only.

MarkdownBlock.combinedSearchableText: computed property hoisted to a let

prism/Models/MarkdownBlock.swift

Why it matters. This was the round-2 blocking finding. scanSource joins its inputs on every read, so reading it for matches(of:) and again per iteration handed a String.Index from one String instance to a different one. It worked only because the two instances compare equal — a latent programmer error, not a style point.

What to look at. prism/Models/MarkdownBlock.swift:1042-1053

Takeaway. In Swift, a String.Index is only valid against the exact instance that produced it. Any computed property that returns a freshly-built String is a trap the moment you take an index into it: bind it to a let at the top of the scope. The sibling site in SearchStateFeeder had already done this, which is what made the asymmetry findable.
Rationale. Stated in the second commit message and in the code comment, which names the mechanism (joins on every read) rather than just saying "hoisted for clarity" — so the next reader cannot undo it as a cosmetic change.

FootnotePreprocessor: the code-span OPENER is now escape-aware

prism/Services/FootnotePreprocessor.swift

Why it matters. A second, independent behaviour bug found during review. The backtick skip treated every backtick as a delimiter, so in \`[^1]` it paired the escaped backtick with the trailing one and swallowed a reference the renderer badges — orphaning a footnote the reader can see. Fixed rather than deferred because it reuses the same predicate.

What to look at. prism/Services/FootnotePreprocessor.swift:352-372 (opener) and :390-410 (reference)

Takeaway. The asymmetry is the interesting part: CommonMark applies backslash escapes OUTSIDE code spans only, so the opening backtick must be escape-aware and the closing run must not. Getting that half-right in either direction is a bug; the comment spells out both halves with a worked example.
Rationale. Second commit message and the inline comment, both citing CommonMark's "Backslash escapes" section with the `foo\`bar` example. Covered in both directions by tests (escaped backtick does not open; escaped-backslash-then-backtick still does).

SearchStateFeeder: the same filter in both scans, or neither

prism/Services/SearchStateFeeder.swift

Why it matters. referencedFootnoteIdentifiers and badgeEligibility must line up entry-for-entry — the second array indexes the first. Adding the escape filter to only one would have silently shifted the current-match badge. Both got it, and four doc comments that previously overclaimed the alignment were reworded to state what is actually true.

What to look at. prism/Services/SearchStateFeeder.swift:254-266 and :353-366; doc rewrites at :42-52, :307-333, :383-392

Takeaway. Two arrays that must stay index-aligned are a coupling the compiler cannot see. The honest response is what happened here: apply the change to both, and write down the conditions under which they can still diverge (here, comment gating) rather than asserting they cannot.
Rationale. Second commit message: the previous comments claimed the scanners "can never disagree" / "stay in lockstep", which the six-scanner inventory disproves. The replacement text scopes the guarantee to escaping and points at the report for residuals.

Three mutation-checked tests for the two private feeder scans

prismTests/WebRendering/WebSearchBridgeTests.swift

Why it matters. The feeder change shipped untested in the first commit. Both scans are private, so the tests observe them through buildStates's payload: matchedFootnoteIds/textMatchCount detect a dropped filter in one, current == .badge(occurrence: 1) detects it in the other.

What to look at. prismTests/WebRendering/WebSearchBridgeTests.swift:620-706

Takeaway. Testing a private function through its public payload is fine, but only if you can say WHICH assertion fails for WHICH mutation — otherwise you have three tests that might all be measuring the same thing. The comment does exactly that, and honestly labels the third test as the premise the other two rest on rather than a third detector.
Rationale. Second commit message records the mutation run (removing the filter from both sites fails two of three). The bugfix report carries the result bundle counts.

Key decisions

Centralise the escape predicate rather than duplicate the renderer's walk.

The renderer's FootnoteReferenceScanner.scan already consumes backslashes left-to-right on a UTF-16 buffer. Rather than copy that shape into the preprocessor, the fix adds a backward run-parity check next to FootnoteData.referencePattern. The two are equivalent because a maximal backslash run is always entered at its first unit. Recorded as Quick Decision Q1.

Name the predicate for the character, not for [^id].

isEscaped(in:before:) rather than isReferenceEscaped — a rename made in the second commit precisely so the preprocessor's code-span skip could apply it to a backtick instead of growing a second parity walk. Recorded as Quick Decision Q2.

Fix the escaped-backtick opener now rather than filing it.

Found during the review round. Deferred work here would have meant a second ticket touching the same twenty lines with the same predicate; the commit message argues it reuses what is already being added. The asymmetry (opener escape-aware, closing run raw) is CommonMark's, not a shortcut.

Leave FootnoteStripping.strip and DocumentReaderView.routeDocumentLink unescaped.

Sites 5 and 6 of the six-scanner inventory. Both pre-existing, both scoped out and recorded in the report so the next reader does not conclude "the scanners agree now". routeDocumentLink can only mis-target a deep link in a document where an escaped occurrence precedes the live one in a different block.

Do not insert a separator when excising HTML comments.

Would fix the A\<!-- x -->[^1] fabricated-escape residual, but changes the searchable text of every commented block — wider than this ticket. Recorded rather than papered over. (See finding 1: the residual as written names only the comments-ON helper.)

Do not consume FootnoteReferenceScanner.scan at the three downstream sites.

Not stated anywhere. The preprocessor genuinely cannot use it (FootnoteData does not exist yet at that stage — the pass is what builds it), but combinedSearchableText and both SearchStateFeeder scans all have context.footnoteData in hand and could have called the scanner instead of re-approximating regex + escape guard + resolvability. The report's Alternatives Considered does not mention it.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorspecs/bugfixes/escaped-footnote-references/report.md:241-247 + prism/Models/MarkdownBlock.swift:1032-1040The newly-introduced residual is documented as narrower than it is. Both the report and the code comment attribute the A\<!-- x -->[^1] -> A\[^1] collapse to HTMLCommentStripping.removing, which is only the comments-VISIBLE branch of footnoteScanComponents. The default branch (showHTMLComments off, MarkdownBlock.swift:1118) uses HTMLImageParser.stripHTMLComments, which also excises with no separator — so the residual applies on the DEFAULT path too. Separately, the code comment calls it a "residual" without saying it is new; a reader at the call site will read that as pre-existing, whereas the report correctly labels it "(new, narrow)".Not fixed — this review was run strictly read-only. Attribute the residual to footnoteScanComponents' excision generally, naming both helpers, and carry the report's "(new, narrow)" label into the code comment. No code change needed; the behaviour is a documented, extremely contrived search miss.
minorprism/Services/SearchStateFeeder.swift:317-320 + report.md:251"currentMatch degrades safely (a missing flag is read as eligible)" is itself a mild overclaim, in a set of comments this branch reworded specifically to remove overclaims. The guard at :401 is `$0 < eligibility.count ? eligibility[$0] : true`, which only covers a SHORT array. The residual described one paragraph earlier is a SHIFT — a shifted-but-not-shorter array is read entry-for-entry and yields the wrong badge occurrence, so the current-match highlight lands on the wrong badge. Not a crash, but not what "degrades safely" implies.Not fixed (read-only). Reword to distinguish the two: a missing flag is read as eligible; a shifted array can address the wrong badge occurrence. Same sentence in the report.
minorprismTests/FootnotePreprocessorTests.swift:955-967testEscapedReferenceDoesNotSkewDisplayNumbers passes against the pre-fix code. Its source uses one identifier for both the escaped and the live occurrence, so `seen` dedupes and both referenceOrder == ["1"] and displayNumber == 1 hold with or without the escape filter. The name promises skew detection the test does not provide. The genuine skew guards are testEscapedReferenceDoesNotConsumeANumberForOtherFootnotes and testEscapedReferenceIsNotCounted, both of which do fail pre-fix.Not fixed (read-only). Either rename to reflect what it actually pins (an escaped and a live occurrence of the same id collapse to one reference) or drop it. Coverage of the bug itself is not affected — its two siblings carry it.
minorspecs/bugfixes/escaped-footnote-references/report.md:128-148The Approach rationale is stale on the site count: it says "rather than adding a THIRD independent escape rule" and "applies it at each of the THREE sites", and Alternatives Considered says "the THREE loops". The report's own inventory table and the code comments both say four fixed sites. The Affected Files table (:196) credits only Quick decision Q1 where Q1 and Q2 were both added, and the Regression Test names list (:153-157) has four entries where Changes made (:120) says seven — the three backtick tests are missing from the list a reader would actually use.Not fixed (read-only). Three number/list corrections in the report; no code implication.
minorspecs/bugfixes/escaped-footnote-references/report.md:73-79 (site 5)The report calls FootnoteStripping.strip removing a \[^1] from a heading "cosmetic and arguably desirable". It is slightly worse than that: strip is `text.replacing(referencePattern, with: "")`, which removes the token but leaves the ORPHAN BACKSLASH, so a heading `Notes \[^1]` yields a ToC entry, anchor and window title of `Notes \` while the document renders `Notes [^1]`. Pre-existing and out of scope, but the characterisation undersells it.Not fixed (read-only). Adjust the wording, or file a follow-up — it becomes a one-line adoption if a combined "live references in text" helper is ever added.
minorprismTests/MarkdownBlockSearchContextTests.swift:208-233The only combinedSearchableText test runs with showHTMLComments: false, so the surfaced-comment-body half of scanSource — the exact text where the documented residual lives — is unpinned in both directions. Multi-backslash coverage also stops at two: \\[^1] is tested, an odd run of three is not, and FootnoteData.isEscaped has no direct test, so parity with the renderer beyond one and two backslashes rests on inspection.Not fixed (read-only). Two cheap additions: one combinedSearchableText case with showHTMLComments: true (escaped reference inside a surfaced comment body, live one outside), and one three-backslash preprocessor case. Neither is a gap in the fix, only in what would catch a future regression.
nitprism/Services/SearchStateFeeder.swift:353-366 / prism/Services/FootnotePreprocessor.swift:401 / prismTests/WebRendering/WebSearchBridgeTests.swift:704Three small things. badgeEligibility runs String(match.1) plus a dictionary lookup in its `where` clause before the cheaper escape guard, where the two sibling sites got the ordering right. FootnotePreprocessor.swift:401 could pass `index` instead of match.range.lowerBound — prefixMatch guarantees they are equal, and it removes the Substring-vs-String provenance question a future reader must re-derive, which is exactly the class of question that cost this branch a review round. escapedReferenceRendersNoBadge hardcodes UTF-16 offsets [29, 43] into a private fixture string, so any edit to that string fails opaquely.Not fixed (read-only). All three are cosmetic; the offsets are at least derived from a fixture in the same file, so the breakage would be local and obvious.
nitprism/Models/FootnoteData.swift:66 (placement)isEscaped is a generic CommonMark backslash-escape predicate — the branch itself proves this by applying it to a backtick — living on FootnoteData, a model of footnote definitions. The doc comment argues the naming but not the placement. Related: RawSourceHighlightParser.swift documents that its (?<![\\*]) lookbehind cannot count backslashes and knowingly mis-handles \\*em*; that known limitation now has a correct implementation in the codebase it cannot naturally reach from a footnote model.Not fixed (read-only), and not worth the churn now. If a third construct ever asks the question, a MarkdownEscaping utility is the honest home, with FootnoteData keeping only referencePattern.

Tests

Source: local run at 2026-09-06T18:55:00+10:00 · snapshot 617d849c2faa849b355dd8e7aff33112e34d22ca

Baseline: none

Execution: passed · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

No test results were read from the inputs.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 118ef1da.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

prism/Models/FootnoteData.swift Modified +39 / -0
diff --git a/prism/Models/FootnoteData.swift b/prism/Models/FootnoteData.swiftindex e084203e..110c12c3 100644--- a/prism/Models/FootnoteData.swift+++ b/prism/Models/FootnoteData.swift@@ -37,6 +37,45 @@ struct FootnoteData: Equatable, Sendable {     /// Centralised here to avoid pattern divergence across the codebase.     nonisolated(unsafe) static let referencePattern = /\[\^([a-zA-Z0-9_-]+)\]/ +    /// Whether the character at `index` in `text` is backslash-escaped, i.e. renders as+    /// literal text rather than as the markdown construct it spells.+    ///+    /// A backslash escapes exactly the character following it, so a run of backslashes+    /// immediately before `index` pairs off left to right: an odd-length run leaves its+    /// last backslash to escape the character (literal), an even-length run (including+    /// zero) is entirely escaped-backslash pairs (live) — `\[^1]` is escaped, `\\[^1]`+    /// is not. This is the same left-to-right consumption `FootnoteReferenceScanner.scan`+    /// performs directly on its UTF-16 buffer (T-1716); counting the preceding run's+    /// parity is equivalent for a `Regex` match, which only hands back where the token+    /// starts, not what already consumed the text before it.+    ///+    /// Shared by `FootnotePreprocessor.scanLineForReferences`, `MarkdownBlock+    /// .combinedSearchableText`, and `SearchStateFeeder`'s two scans, so those four sites+    /// cannot each invent their own answer to "is this occurrence escaped?" — four copies+    /// of the rule is what let the preprocessor disagree with the renderer (T-1968).+    /// Deliberately generic rather than reference-shaped: the preprocessor asks the same+    /// question of a code-span-opening backtick, which CommonMark escapes by exactly this+    /// rule, and a second copy of the parity walk there would reintroduce the same class.+    ///+    /// Scope, precisely: this settles ESCAPING only. Those sites still approximate the+    /// renderer in other ways, and known residuals are listed in+    /// `specs/bugfixes/escaped-footnote-references/report.md` — the preprocessor's+    /// line-at-a-time scan cannot see indented code blocks or a code span that spans+    /// lines, and its closing-run match does not require the run lengths to be equal;+    /// `FootnoteStripping.strip` and `DocumentReaderView.routeDocumentLink` do not consult+    /// this predicate at all. Do not read "shared predicate" as "the scanners agree".+    nonisolated static func isEscaped(in text: String, before index: String.Index) -> Bool {+        var count = 0+        var cursor = index+        while cursor > text.startIndex {+            let previous = text.index(before: cursor)+            guard text[previous] == "\\" else { break }+            count += 1+            cursor = previous+        }+        return count % 2 == 1+    }+     nonisolated var isEmpty: Bool { definitions.isEmpty }      nonisolated func definition(for identifier: String) -> FootnoteDefinition? {
prism/Models/MarkdownBlock.swift Modified +30 / -2
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex b48f8128..65e4a78d 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -1019,9 +1019,37 @@ nonisolated enum MarkdownBlock: Identifiable, Equatable, Sendable {         // are visible, additionally scan the surfaced comment bodies so         // references inside rendered comment ranges contribute consistently         // with the render — but references inside rejected (never-rendered)-        // comment shapes still contribute nothing.+        // comment shapes still contribute nothing. A backslash-escaped+        // occurrence (`\[^1]`) renders as literal text, never a live+        // reference, so it must not pull in a footnote's content just+        // because some OTHER, live occurrence of the same id resolves it+        // (T-1968) — `SearchStateFeeder.referencedFootnoteIdentifiers`+        // applies the same predicate over the same `scanSource`, so the two+        // agree on ESCAPING; they still differ on comment gating, because+        // `SearchStateFeeder.badgeEligibility` scans the raw source instead+        // (pre-existing, noted at that call site).+        //+        // Known residual (T-1968 review): `HTMLCommentStripping.removing`+        // excises a comment span leaving NO separator behind, so+        // `A\<!-- x -->[^1]` collapses to `A\[^1]` here and the reference is+        // read as escaped, while the renderer badges it — cmark sees `\<`+        // as an escaped `<`, so no comment ever opened. The mis-detection is+        // upstream in the comment scan, not in the escape rule; recorded in+        // `specs/bugfixes/escaped-footnote-references/report.md` rather than+        // papered over here, since inserting a separator would change the+        // searchable text of every commented block.         if !context.footnoteData.isEmpty {-            for match in components.scanSource.matches(of: footnoteReferenceRegex) {+            // Hoisted: `scanSource` is COMPUTED (it joins the base with the surfaced+            // comment bodies on every read), so reading it once for `matches(of:)` and+            // again inside the loop would hand a `String.Index` derived from one String+            // instance to a different one — a programmer error in Swift that happens to+            // work only while the two instances compare equal. `SearchStateFeeder` hoists+            // it at its sibling site for the same reason.+            let scanSource = components.scanSource+            for match in scanSource.matches(of: footnoteReferenceRegex) {+                guard !FootnoteData.isEscaped(in: scanSource, before: match.range.lowerBound) else {+                    continue+                }                 let identifier = String(match.1)                 if let definition = context.footnoteData.definition(for: identifier) {                     result += " " + stripMarkdownFormatting(definition.content)
prism/Services/FootnotePreprocessor.swift Modified +35 / -4
diff --git a/prism/Services/FootnotePreprocessor.swift b/prism/Services/FootnotePreprocessor.swiftindex 3dc984ca..10ff0c56 100644--- a/prism/Services/FootnotePreprocessor.swift+++ b/prism/Services/FootnotePreprocessor.swift@@ -350,6 +350,25 @@ enum FootnotePreprocessor: Sendable {          while index < line.endIndex {             if line[index] == "`" {+                // A backslash-escaped backtick is literal text and cannot OPEN a code+                // span (CommonMark "Backslash escapes": `\`not code`` renders the+                // backticks verbatim). Without this the escaped backtick was taken as an+                // opener, so in `` \`[^1]` `` the unmatched trailing backtick closed a+                // code span that never opened and swallowed the reference — while the+                // renderer, parsing the same line through cmark, badges it live. Same+                // odd-backslash-run parity the reference check below uses, from the same+                // predicate, because a divergent second copy is the whole failure class+                // T-1968 is closing.+                //+                // Only the OPENER is escape-aware. Backslash escapes do not apply inside+                // a code span, so the closing run below is matched raw: CommonMark's+                // `` `foo\`bar` `` is `<code>foo\</code>` followed by literal ``bar` ``,+                // not a span running to the final backtick.+                if FootnoteData.isEscaped(in: line, before: index) {+                    index = line.index(after: index)+                    continue+                }+                 var backtickCount = 0                 var btIndex = index                 while btIndex < line.endIndex && line[btIndex] == "`" {@@ -369,10 +388,22 @@ enum FootnotePreprocessor: Sendable {             if line[index] == "[" {                 let remaining = line[index...]                 if let match = remaining.prefixMatch(of: referencePattern) {-                    let identifier = String(match.1)-                    if !seen.contains(identifier) {-                        order.append(identifier)-                        seen.insert(identifier)+                    // A backslash-escaped reference (`\[^1]`) renders as literal text, not+                    // a live reference — it must not consume a display number or keep an+                    // otherwise-orphaned definition alive. Escape-aware the same way+                    // `FootnoteReferenceScanner.scan` decides live vs. literal, so this+                    // scan and the renderer's badge substitution agree on ESCAPING+                    // (T-1968). They can still disagree on other grounds — this scan is+                    // line-at-a-time and regex-based, so an indented code block or a code+                    // span crossing a line boundary is invisible to it, and its+                    // closing-run match does not require equal run lengths. Residuals are+                    // listed in `specs/bugfixes/escaped-footnote-references/report.md`.+                    if !FootnoteData.isEscaped(in: line, before: match.range.lowerBound) {+                        let identifier = String(match.1)+                        if !seen.contains(identifier) {+                            order.append(identifier)+                            seen.insert(identifier)+                        }                     }                     index = match.range.upperBound                     continue
prism/Services/SearchStateFeeder.swift Modified +48 / -17
diff --git a/prism/Services/SearchStateFeeder.swift b/prism/Services/SearchStateFeeder.swiftindex 351620fb..441dcf0d 100644--- a/prism/Services/SearchStateFeeder.swift+++ b/prism/Services/SearchStateFeeder.swift@@ -42,12 +42,14 @@ enum SearchStateFeeder {         /// plus the badge occurrence (never a text ordinal — appended footnote         /// text has no rendered equivalent). `occurrence` is the 0-based index among         /// the block's BADGE-RENDERING references to that SAME identifier in source-        /// order — i.e. the badge's DOM index within the section. References that-        /// resolve to a footnote but never render a badge (code spans, escaped-        /// tokens, link-nested text — `FootnoteReferenceScanner`/-        /// `InlineHTMLRenderer.Walker` own that classification) still occupy-        /// match-ordinal space, but are excluded from the occurrence count because-        /// they have no DOM badge to address (T-1853).+        /// order — i.e. the badge's DOM index within the section. A reference that+        /// resolves to a footnote but never renders a badge (a code span or+        /// link-nested text — `FootnoteReferenceScanner`/`InlineHTMLRenderer.Walker`+        /// own that classification) still occupies match-ordinal space, but is+        /// excluded from the occurrence count because it has no DOM badge to address+        /// (T-1853). A backslash-escaped occurrence occupies no ordinal space at all —+        /// it is excluded upstream, same as `FootnotePreprocessor`'s reference scan,+        /// because it is never a reference in the first place (T-1968).         case badge(id: String, occurrence: Int)     } @@ -250,6 +252,14 @@ enum SearchStateFeeder {                 .footnoteScanComponents(forInlineText: source, context: context)                 .scanSource             for match in scanSource.matches(of: FootnoteData.referencePattern) {+                // Escaped occurrences (`\[^1]`) render as literal text, never a live+                // reference — excluded here, and by the identical predicate in+                // `badgeEligibility` below, so escaping alone cannot pull the two+                // arrays out of alignment (T-1968). Comment gating still can: see the+                // note on `badgeEligibility`.+                guard !FootnoteData.isEscaped(in: scanSource, before: match.range.lowerBound) else {+                    continue+                }                 identifiers.append(String(match.1))             }         }@@ -294,17 +304,29 @@ enum SearchStateFeeder {         return sources     } -    /// Badge-eligibility flags aligned one-to-one with `footnoteMatchCounts`'s-    /// entries (same inline-source iteration, same reference regex, same-    /// resolvable-identifier filter). A flag is true when that reference occurrence-    /// actually renders as a badge in the DOM.+    /// Badge-eligibility flags intended to line up one-to-one with+    /// `footnoteMatchCounts`'s entries: same inline-source iteration, same reference+    /// regex, same resolvable-identifier filter, and — since T-1968 — the same escape+    /// predicate, so an escaped occurrence is dropped on both sides rather than+    /// contributing a flag with no matching entry. A flag is true when that reference+    /// occurrence actually renders as a badge in the DOM.+    ///+    /// The alignment is not unconditional, and the gap is pre-existing: this scans the+    /// RAW inline source, while `referencedFootnoteIdentifiers` scans the+    /// comment-gated `scanSource` (`MarkdownBlock.footnoteScanComponents`). For a block+    /// containing an HTML comment the two iterate different text, so a reference inside+    /// a hidden comment — or one whose escape status the comment excision changes — can+    /// still shift the arrays relative to each other. `currentMatch` degrades safely+    /// (a missing flag is read as eligible), and the residual is recorded in+    /// `specs/bugfixes/escaped-footnote-references/report.md`.     ///     /// Eligibility comes from the render pipeline itself     /// (`FootnoteReferenceScanner` candidacy plus the walker's position rules),     /// matched back to each regex occurrence by its UTF-16 source offset — never     /// re-approximated here, which is the failure class the badge redesign removed     /// (T-1853 review). A `[^id]`-shaped occurrence that resolves but never badges-    /// (code span, escape, link nesting) gets `false`.+    /// (code span, link nesting) gets `false`; an escaped occurrence never reaches+    /// this array at all (excluded upstream, same as `referencedFootnoteIdentifiers`).     ///     /// `cachedStarts` supplies the starts the document emit already captured     /// (`EmittedDocument.badgeSourceStarts`, cached per `parseRevision`), so the@@ -330,6 +352,13 @@ enum SearchStateFeeder {             let badgeStarts = Set(starts)             for match in source.matches(of: FootnoteData.referencePattern)             where context.footnoteData.definition(for: String(match.1)) != nil {+                // Kept in lockstep with `referencedFootnoteIdentifiers`'s escape check+                // above: an escaped occurrence never enters that array, so it must not+                // contribute a flag here either, or the two would misalign entry-for-+                // entry (T-1968).+                guard !FootnoteData.isEscaped(in: source, before: match.range.lowerBound) else {+                    continue+                }                 let offset = source.utf16.distance(from: source.startIndex, to: match.range.lowerBound)                 flags.append(badgeStarts.contains(offset))             }@@ -354,12 +383,14 @@ enum SearchStateFeeder {         // matches in order and find which reference occurrence owns this overflow         // position. The occurrence is the winner's DOM badge index: the count of         // PRIOR same-id entries that actually render as badges. Entries that resolve-        // but never badge (code span, escape, link nesting) occupy ordinal space —-        // their definition content is appended to the searchable text — but have no-        // DOM badge, so counting them would skew the index off the section's real-        // badge list (T-1853 review). Eligibility is resolved lazily and only for-        // the one block that owns the current match — normally a lookup of the-        // emit-captured starts, with a renderer fallback only on a cache miss.+        // but never badge (code span, link nesting) occupy ordinal space — their+        // definition content is appended to the searchable text — but have no DOM+        // badge, so counting them would skew the index off the section's real badge+        // list (T-1853 review). A backslash-escaped occurrence is never one of these+        // entries at all — `footnoteMatches` excludes it upstream (T-1968). Eligibility+        // is resolved lazily and only for the one block that owns the current match —+        // normally a lookup of the emit-captured starts, with a renderer fallback only+        // on a cache miss.         var remaining = index - textMatchCount         for (matchIndex, footnote) in footnoteMatches.enumerated() where footnote.count > 0 {             if remaining < footnote.count {
prismTests/FootnotePreprocessorTests.swift Modified +119 / -0
diff --git a/prismTests/FootnotePreprocessorTests.swift b/prismTests/FootnotePreprocessorTests.swiftindex 7e9995d9..62644a98 100644--- a/prismTests/FootnotePreprocessorTests.swift+++ b/prismTests/FootnotePreprocessorTests.swift@@ -941,6 +941,125 @@ struct FootnotePreprocessorTests {         #expect(!result.cleanedSource.contains("Should be orphaned"))     } +    @Test("Backslash-escaped reference is not counted as a reference")+    func testEscapedReferenceIsNotCounted() {+        let source = """+        Text with an escaped \\[^1] that renders as literal text.++        [^1]: Should be orphaned.+        """+        let result = FootnotePreprocessor.process(source)++        // The escaped occurrence is literal text, not a reference, so the+        // definition it would otherwise keep alive is orphaned (T-1968).+        #expect(result.footnoteData.definition(for: "1") == nil)+        #expect(!result.cleanedSource.contains("Should be orphaned"))+    }++    @Test("Escaped reference preceding a live one does not skew display numbers")+    func testEscapedReferenceDoesNotSkewDisplayNumbers() {+        let source = """+        First an escaped \\[^1] then a real[^1] reference.++        [^1]: The only definition.+        """+        let result = FootnotePreprocessor.process(source)++        // The escaped occurrence must not consume a display number before the+        // live one is reached (T-1968): the live reference is still number 1.+        #expect(result.footnoteData.definition(for: "1")?.displayNumber == 1)+        #expect(result.footnoteData.referenceOrder == ["1"])+    }++    @Test("Escaped reference before a different live footnote leaves numbering untouched")+    func testEscapedReferenceDoesNotConsumeANumberForOtherFootnotes() {+        let source = """+        An escaped \\[^skipped] comes first, then a real[^live] reference.++        [^skipped]: Orphaned — never referenced live.+        [^live]: The live one.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.definition(for: "skipped") == nil)+        #expect(result.footnoteData.definition(for: "live")?.displayNumber == 1)+        #expect(result.footnoteData.referenceOrder == ["live"])+    }++    @Test("Escaped backslash before a reference does not escape it")+    func testEscapedBackslashLeavesReferenceLive() {+        let source = """+        A literal backslash then a reference: \\\\[^1].++        [^1]: Still live.+        """+        let result = FootnotePreprocessor.process(source)++        // `\\` is an escaped backslash, not an escape of `[` — the reference stays+        // live, matching `FootnoteReferenceScanner`'s left-to-right rule (T-1716,+        // T-1968).+        #expect(result.footnoteData.definition(for: "1")?.displayNumber == 1)+    }++    @Test("An escaped backtick does not open a code span that swallows a reference")+    func testEscapedBacktickDoesNotOpenCodeSpan() {+        // The line is literally: Escaped \`[^1]` still counts.+        // The leading backtick is backslash-escaped, so per CommonMark it is literal+        // text and opens nothing; the trailing backtick is an unmatched literal. The+        // reference between them is live. Before T-1968's review the scan took the+        // escaped backtick as an opener and matched it against the trailing one,+        // swallowing `[^1]` as code — while the renderer, parsing the same line through+        // cmark, badged it. See `testEscapedBacktickAgreesWithTheRenderer` for the+        // parity assertion that pins the two together.+        let source = """+        Escaped \\`[^1]` still counts.++        [^1]: The definition.+        """+        let result = FootnotePreprocessor.process(source)++        #expect(result.footnoteData.referenceOrder == ["1"])+        #expect(result.footnoteData.definition(for: "1")?.displayNumber == 1)+        #expect(result.footnoteData.definition(for: "1")?.content == "The definition.")+        // The reference itself survives into the cleaned source for the renderer to+        // badge — only the DEFINITION line is extracted out of it.+        #expect(result.cleanedSource.contains("[^1]"))+    }++    @Test("Preprocessor and renderer agree on a reference after an escaped backtick")+    func testEscapedBacktickAgreesWithTheRenderer() {+        // The same line, asked of both components that classify `[^1]` independently:+        // the preprocessor's reference scan (which assigns display numbers) and the+        // renderer's badge substitution. Disagreement here is exactly the failure class+        // T-1968 closes — the preprocessor would orphan a footnote the reader can see.+        let line = "Escaped \\`[^1]` still counts."+        let result = FootnotePreprocessor.process("\(line)\n\n[^1]: The definition.")+        let html = FootnoteRenderProbe.emit(line)++        #expect(FootnoteRenderProbe.badgeCount(html) == 1)+        #expect(result.footnoteData.referenceOrder == ["1"])+    }++    @Test("An escaped backslash before a backtick still opens a real code span")+    func testEscapedBackslashBeforeBacktickStillOpensCodeSpan() {+        // The line is literally: Literal backslash \\`[^1]` end.+        // `\\` is an escaped backslash, so the backtick that follows is NOT escaped and+        // opens a genuine code span — the reference inside it renders as literal text+        // and must stay orphaned. The parity check guards the other direction of the+        // fix: making the opener escape-aware must not make every backtick literal.+        let source = """+        Literal backslash \\\\`[^1]` end.++        [^1]: Should be orphaned.+        """+        let result = FootnotePreprocessor.process(source)+        let html = FootnoteRenderProbe.emit("Literal backslash \\\\`[^1]` end.")++        #expect(result.footnoteData.definition(for: "1") == nil)+        #expect(!result.cleanedSource.contains("Should be orphaned"))+        #expect(FootnoteRenderProbe.badgeCount(html) == 0)+    }+     @Test("Empty source produces empty result")     func testEmptySource() {         let result = FootnotePreprocessor.process("")
prismTests/MarkdownBlockSearchContextTests.swift Modified +28 / -0
diff --git a/prismTests/MarkdownBlockSearchContextTests.swift b/prismTests/MarkdownBlockSearchContextTests.swiftindex 04c0f088..771d68e2 100644--- a/prismTests/MarkdownBlockSearchContextTests.swift+++ b/prismTests/MarkdownBlockSearchContextTests.swift@@ -205,6 +205,34 @@ struct MarkdownBlockSearchContextTests {         )     } +    // MARK: - Backslash-escaped footnote references (T-1968)++    /// A backslash-escaped reference (`\[^1]`) renders as literal text, never a+    /// badge, so it must not pull a footnote's content into a block's searchable+    /// text just because some OTHER, live occurrence of the same id resolves it.+    @Test("escaped footnote reference alongside a live one still only contributes once")+    func escapedFootnoteReferenceDoesNotDoubleContribute() {+        let footnote = FootnoteDefinition(+            identifier: "1",+            displayNumber: 1,+            content: "footnote body"+        )+        let data = FootnoteData(+            definitions: ["1": footnote],+            referenceOrder: ["1"]+        )+        let block = MarkdownBlock.paragraph(+            markdown: "An escaped \\[^1] and a live[^1] reference."+        )+        let context = SearchContext(showHTMLComments: false, footnoteData: data)+        let text = block.searchableText(in: context)+        let occurrences = text.components(separatedBy: "footnote body").count - 1+        #expect(+            occurrences == 1,+            "the escaped occurrence must not append the definition a second time; got: \(text)"+        )+    }+     // MARK: - Rejected comment shapes (search/DOM parity, T-1638)      /// The render path routes every inline `<!--…-->` span through
prismTests/WebRendering/WebSearchBridgeTests.swift Modified +87 / -0
diff --git a/prismTests/WebRendering/WebSearchBridgeTests.swift b/prismTests/WebRendering/WebSearchBridgeTests.swiftindex 470392b4..71abc446 100644--- a/prismTests/WebRendering/WebSearchBridgeTests.swift+++ b/prismTests/WebRendering/WebSearchBridgeTests.swift@@ -617,6 +617,93 @@ struct WebSearchBridgeTests {         #expect(states.first?.current == .badge(id: "1", occurrence: 0))     } +    // MARK: - T-1968: an escaped occurrence occupies no ordinal space at all++    /// The source used by the escape-alignment tests below. Literally:+    /// `Escaped \[^1] alpha then live[^1] and again[^1] end.`+    ///+    /// One escaped `[^1]` followed by two live ones, and the word "alpha" — which is+    /// also footnote 1's content — once in the visible text. So the block's ordinal+    /// space is [1 text match][2 footnote-content matches], one per LIVE reference.+    private static let escapedThenLiveSource =+        "Escaped \\[^1] alpha then live[^1] and again[^1] end."++    @Test("An escaped reference is excluded from both feeder arrays, keeping them aligned")+    func escapedReferenceKeepsFeederArraysAligned() {+        // `referencedFootnoteIdentifiers` and `badgeEligibility` are private, and their+        // documented contract is that they stay aligned entry-for-entry — so the escape+        // filter has to be observed through the payload. Each assertion below fails+        // against a different half of it:+        //+        //  - `matchedFootnoteIds == ["1", "1"]` and `textMatchCount == 1` fail if+        //    `referencedFootnoteIdentifiers` drops its filter: the escaped occurrence+        //    would add a third footnote match, over-claiming the block's 3-match total+        //    and clamping the visible-text portion to 0.+        //  - `current == .badge(id: "1", occurrence: 1)` fails if `badgeEligibility`+        //    drops its filter: the escaped occurrence renders no badge, so its `false`+        //    flag would land at index 0 and push the current badge back to occurrence 0,+        //    marking the FIRST badge instead of the second.+        let blocks: [MarkdownBlock] = [.paragraph(markdown: Self.escapedThenLiveSource)]+        let context = footnoteContext()+        let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+        // 1 visible + 1 per live reference. A fourth would mean the escaped occurrence+        // pulled footnote 1's content into the searchable text (the same blind spot on+        // the `combinedSearchableText` side).+        #expect(counts[0] == 3)++        let states = SearchStateFeeder.buildStates(+            query: "alpha",+            blocks: blocks,+            matchCountsPerBlock: counts,+            currentGlobalMatchIndex: 2,+            context: context+        )+        #expect(states.count == 1)+        #expect(states.first?.textMatchCount == 1)+        #expect(states.first?.matchedFootnoteIds == ["1", "1"])+        #expect(states.first?.current == .badge(id: "1", occurrence: 1))+    }++    @Test("Match ordinals across an escaped reference address the right text and badges")+    func escapedReferenceOrdinalsWalkTheLiveOccurrences() {+        // The whole ordinal walk over the same source, one index at a time: 0 is the+        // visible "alpha", then one footnote-content match per live reference — the+        // section's first and second badges. Nothing addresses the escaped occurrence,+        // because it never entered the ordinal space.+        let blocks: [MarkdownBlock] = [.paragraph(markdown: Self.escapedThenLiveSource)]+        let context = footnoteContext()+        let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }++        let expected: [SearchStateFeeder.CurrentMatch] = [+            .text(ordinal: 0),+            .badge(id: "1", occurrence: 0),+            .badge(id: "1", occurrence: 1),+        ]+        for (index, want) in expected.enumerated() {+            let states = SearchStateFeeder.buildStates(+                query: "alpha",+                blocks: blocks,+                matchCountsPerBlock: counts,+                currentGlobalMatchIndex: index,+                context: context+            )+            #expect(states.first?.current == want, "global match index \(index)")+        }+    }++    @Test("The escaped occurrence renders no badge, so the DOM list is the two live ones")+    func escapedReferenceRendersNoBadge() {+        // The premise the two tests above rest on: the render pipeline itself reports+        // badge starts only for the live occurrences, so a feeder that counted the+        // escaped one would be indexing against a DOM list that does not contain it.+        let starts = InlineHTMLRenderer.badgeSourceStarts(+            source: Self.escapedThenLiveSource, footnotes: footnoteData()+        )+        #expect(starts.count == 2)+        // Offsets of the two live `[^1]` tokens in the source, in DOM order.+        #expect(starts == [29, 43])+    }+     @Test("Live: current badge lands on the right badge across a code-span look-alike")     func liveCurrentBadgeSkipsCodeSpanLookAlike() async throws {         let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] here `[^1]` and also[^1] end.")]
specs/bugfixes/escaped-footnote-references/report.md Added +323 / -0
diff --git a/specs/bugfixes/escaped-footnote-references/report.md b/specs/bugfixes/escaped-footnote-references/report.mdnew file mode 100644index 00000000..b4c45816--- /dev/null+++ b/specs/bugfixes/escaped-footnote-references/report.md@@ -0,0 +1,323 @@+# Bugfix Report: Escaped Footnote References Skew Display Numbers++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++`FootnotePreprocessor.scanLineForReferences` counted a backslash-escaped footnote+reference such as `\[^1]` as a real reference. `\[^1]` renders as literal text — the+reader never sees a badge — but the scan still recorded its identifier in+`referenceOrder`, so it consumed a display number nothing displayed and pushed every+subsequent live footnote's number one higher. The same escaped occurrence could also+keep a definition alive that should have been orphan-filtered, since the scan treated+it as a genuine reference to that definition.++The scan already special-cased backtick-delimited inline code (a `[^1]` inside+`` ` `` `` ` `` is correctly excluded), so the missing backslash handling was an+inconsistency rather than an oversight — and it disagreed with+`FootnoteReferenceScanner.scan` (the renderer's badge-substitution pass, T-1716),+which has been escape-aware since it was introduced.++**Reproduction steps:**+1. Write a document containing `First an escaped \[^1] then a real[^1] reference.`+   followed by a single `[^1]: The only definition.`+2. Parse it with `FootnotePreprocessor.process(_:)`.+3. Observe: prior to the fix the escaped occurrence was counted first, so numbering+   drifted once a second, independently-referenced footnote existed ahead of it in+   scan order (a single-footnote document happens to still show `1`, which is why+   this was easy to miss).++**Impact:** Any document mixing an escaped `\[^id]` occurrence with genuine footnote+references got footnote numbering one higher than it should be from that point+onward, and a footnote referenced only via an escaped occurrence was kept alive+(rendered) instead of being dropped as orphaned.++## Investigation Summary++- **Symptoms examined:** `scanLineForReferences`'s character-by-character walk over+  each line: it special-cases backtick spans (skipping code-span content entirely)+  and then matches `FootnoteData.referencePattern` (`/\[\^([a-zA-Z0-9_-]+)\]/`)+  directly against remaining text with no backslash awareness at all.+- **Code inspected:** `FootnotePreprocessor.swift` (reference scan and its state+  machine, reworked two days prior by T-1963 for HTML-block extent — unrelated to+  this scan, which sits in Phase 4 and was untouched by that change),+  `FootnoteReferenceScanner.swift` (the renderer's escape-aware candidacy pass,+  T-1716), `MarkdownBlock.swift` (`combinedSearchableText`) and+  `SearchStateFeeder.swift` (`referencedFootnoteIdentifiers`, `badgeEligibility`) —+  the two search-side consumers the ticket asked to check for the same blind spot.+- **Hypotheses tested:** Whether the T-1963 rework (CommonMark HTML-block handling)+  touched the reference scan — it did not; Phase 4 (`scanReferences`/+  `scanLineForReferences`) is a separate pass from Phase 1's block-extent state+  machine. Whether the search feeders already filtered escaped occurrences via some+  other path (e.g. relying on `FootnoteData.definition(for:)` returning `nil`) —+  confirmed they do not: both scan raw source with the same unescaped regex,+  independently of the preprocessor.++## Discovered Root Cause++Several components each independently decided what counts as a `[^id]` reference.+`FootnoteReferenceScanner.scan` (badge substitution) has been escape-aware since+T-1716; nothing else was. The full inventory of unescaped scanners at the time of the+fix — the review's correction to this report's original "three components" framing:++| # | Site | Purpose | Fixed here? |+|---|------|---------|-------------|+| 1 | `FootnotePreprocessor.scanLineForReferences` | Numbering + orphan filtering | Yes |+| 2 | `MarkdownBlock.combinedSearchableText` | Search text expansion | Yes |+| 3 | `SearchStateFeeder.referencedFootnoteIdentifiers` | Matched-footnote ids | Yes |+| 4 | `SearchStateFeeder.badgeEligibility` | Current-badge occurrence | Yes |+| 5 | `FootnoteStripping.strip` | ToC/anchor/title text | No — pre-existing |+| 6 | `DocumentReaderView.routeDocumentLink` | Badge deep-link block lookup | No — pre-existing |++Sites 5 and 6 are out of scope and are not regressions: `strip` removing a `\[^1]`+from a heading is cosmetic and arguably desirable, and `routeDocumentLink`'s+`textContent.contains("[^id]")` can only pick the wrong block for a badge the user+tapped, which requires a document where the escaped occurrence precedes the live one+in a different block. Both are recorded here so the next person does not have to+rediscover that "the scanners agree now" is false.++**Defect type:** Logic error — a missing predicate (backslash-escape awareness) in a+regex-based scan, present in four of six components that need to agree.++**Why it occurred:** The escape-aware rule was added to `FootnoteReferenceScanner.scan`+in T-1716 when badge substitution was redesigned, but nothing propagated that rule+back to `FootnotePreprocessor` (a much older component) or to the search feeders,+which were each written against the same regex without reference to the others.++**Contributing factors:** The reference-counting rule lives in several separate files+with no shared predicate before this fix — `FootnoteData.referencePattern` was+already centralised, but "is this match escaped" was not.++## Resolution for the Issue++**Changes made:**+- `prism/Models/FootnoteData.swift` — added `FootnoteData.isEscaped(in:before:)`, a+  shared predicate: the character at an index is escaped when it is preceded by an+  odd-length run of backslashes (each backslash escapes exactly the character after+  it, so pairs of backslashes escape each other and leave the following character+  live — equivalent to `FootnoteReferenceScanner.scan`'s left-to-right consumption+  for a single following token). Named for the character rather than for `[^id]`+  because the preprocessor asks the same question of a code-span-opening backtick+  (see the review round below); a reference-shaped name would have invited a second+  copy of the parity walk there.+- `prism/Services/FootnotePreprocessor.swift` — `scanLineForReferences` now skips+  recording an identifier when its match is escaped per the shared predicate, while+  still advancing past the matched span so scanning continues correctly. Its+  code-span skip also treats a backslash-escaped BACKTICK as literal text rather than+  as a code-span opener (see the review round below).+- `prism/Models/MarkdownBlock.swift` — `combinedSearchableText`'s footnote-content+  append loop now skips an escaped occurrence, so it cannot pull a footnote's content+  into a block's searchable text merely because a different, live occurrence of the+  same id resolves it.+- `prism/Services/SearchStateFeeder.swift` — `referencedFootnoteIdentifiers` and+  `badgeEligibility` (which must stay aligned entry-for-entry per their existing+  "same inline-source iteration, same reference regex, same resolvable-identifier+  filter" contract) both gained the same escape filter, plus doc-comment corrections+  removing "escape" from the list of categories that "resolve but never badge yet+  still occupy match-ordinal space" — an escaped occurrence now occupies no ordinal+  space at all, since it is not a reference.+- `prismTests/FootnotePreprocessorTests.swift` — seven new regression tests (four for+  the escape predicate, three for the escaped-backtick opener).+- `prismTests/MarkdownBlockSearchContextTests.swift` — one new regression test for the+  search-side blind spot.+- `prismTests/WebRendering/WebSearchBridgeTests.swift` — three new tests driving+  `SearchStateFeeder.buildStates` over an escaped-plus-live reference pair.+- `CHANGELOG.md` — `[Unreleased] / Fixed` entry.++**Approach rationale:** Rather than adding a third independent escape rule, the fix+centralises the predicate `FootnoteReferenceScanner.scan` already uses (backslash-run+parity) in `FootnoteData`, the file that already centralises the shared reference+regex, and applies it at each of the three sites that scan for `[^id]` outside the+renderer. This directly addresses the ticket's stated concern — three components+independently deciding what counts as a reference — without touching the renderer,+which was already correct.++**Alternatives considered:**+- **Give `FootnotePreprocessor` its own index-based backslash-skip loop** (mirroring+  `FootnoteReferenceScanner`'s UTF-16 buffer walk) — rejected: it would duplicate the+  escape rule a third time in a different shape (index-skipping vs. parity-checking),+  reintroducing exactly the drift risk the ticket flags, for no behavioural gain over+  a shared predicate.+- **Leave the search feeders as a "finding" without a code fix** — considered, since+  the ordinal-space bookkeeping between `combinedSearchableText`,+  `referencedFootnoteIdentifiers`, and `badgeEligibility` is order-sensitive.+  Rejected once inspection showed the three loops already share "same inline-source+  iteration, same reference regex, same resolvable-identifier filter" as a documented+  invariant — adding "same escape filter" to all three keeps that invariant intact+  rather than fighting it, so the fix is contained.++## Regression Test++**Test file:** `prismTests/FootnotePreprocessorTests.swift`+**Test names:**+- `testEscapedReferenceIsNotCounted`+- `testEscapedReferenceDoesNotSkewDisplayNumbers`+- `testEscapedReferenceDoesNotConsumeANumberForOtherFootnotes`+- `testEscapedBackslashLeavesReferenceLive`++**What it verifies:** An escaped `\[^1]` orphans its definition instead of keeping it+alive; an escaped reference preceding a live reference to the SAME id leaves the live+one at display number 1; an escaped reference to a DIFFERENT, otherwise-unreferenced+id does not consume a number that should belong to a later live footnote; an escaped+*backslash* (`\\[^1]`) does not escape the reference that follows it, matching+`FootnoteReferenceScanner`'s rule.++**Test file:** `prismTests/MarkdownBlockSearchContextTests.swift`+**Test name:** `escapedFootnoteReferenceDoesNotDoubleContribute`++**What it verifies:** With an escaped `\[^1]` and a live `[^1]` in the same block, the+footnote's definition content is appended to the block's searchable text exactly once,+not twice.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -configuration Debug -derivedDataPath ./DerivedData \+  -resultBundlePath ./DerivedData/t.xcresult -testPlan prism -only-test-configuration "en (base)" \+  -parallel-testing-worker-count 1 \+  -only-testing:prismTests/FootnotePreprocessorTests \+  -only-testing:prismTests/FootnotePreprocessorHTMLBlockExtentTests \+  -only-testing:prismTests/FootnotePreprocessorBlockExtentParityTests \+  -only-testing:prismTests/MarkdownBlockSearchContextTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Models/FootnoteData.swift` | Added shared `isEscaped(in:before:)` predicate |+| `prism/Services/FootnotePreprocessor.swift` | `scanLineForReferences` now skips escaped matches |+| `prism/Models/MarkdownBlock.swift` | `combinedSearchableText` now skips escaped matches |+| `prism/Services/SearchStateFeeder.swift` | `referencedFootnoteIdentifiers`/`badgeEligibility` now skip escaped matches; doc comments corrected |+| `prismTests/FootnotePreprocessorTests.swift` | 7 new regression tests |+| `prismTests/MarkdownBlockSearchContextTests.swift` | 1 new regression test |+| `prismTests/WebRendering/WebSearchBridgeTests.swift` | 3 new regression tests |+| `specs/footnotes/decision_log.md` | Quick decision Q1 (escape rule) |+| `CHANGELOG.md` | `[Unreleased] / Fixed` entries |++## Review Round (PR #416)++The local review confirmed the escape predicate itself and raised two items; both were+actioned, along with three follow-ups from the pre-push review.++**1. `SearchStateFeeder` had no direct test.** Added three to+`prismTests/WebRendering/WebSearchBridgeTests.swift`, driving `buildStates` over+`Escaped \[^1] alpha then live[^1] and again[^1] end.` — an escaped occurrence, two+live ones, and a visible "alpha" that is also footnote 1's content. Each assertion+fails against a different half of the filter, verified by mutation: dropping the+filter from `referencedFootnoteIdentifiers` breaks `textMatchCount == 1` and+`matchedFootnoteIds == ["1", "1"]` (the escaped entry over-claims the block's total),+and dropping it from `badgeEligibility` breaks+`current == .badge(id: "1", occurrence: 1)` (the escaped entry's `false` flag lands at+index 0 and pushes the current badge back one).++**2. The code-span backtick skip was not escape-aware.** Fixed rather than deferred,+because it reuses the same predicate. Per CommonMark, a backslash-escaped backtick is+literal text and cannot OPEN a code span, so `` \`[^1]` `` is a live reference followed+by an unmatched literal backtick — but the skip took the escaped backtick as an opener,+matched it against the trailing one, and swallowed the reference, orphaning a footnote+the reader can see. Only the OPENER is escape-aware: backslash escapes do not apply+inside a code span, so the closing run is still matched raw (CommonMark's+`` `foo\`bar` `` is `<code>foo\</code>` followed by literal ``bar` ``). Three tests,+one of them asserting preprocessor/renderer parity directly through+`FootnoteRenderProbe`.++**3. `String.Index` across two instances of a computed property (blocking).**+`MarkdownBlock.combinedSearchableText` read `components.scanSource` — which JOINS its+inputs on every read — once for `matches(of:)` and again per iteration as the escape+predicate's `text`, so an index from one String instance subscripted a different one.+That is a programmer error in Swift and worked only because the instances compare+equal. Hoisted to a `let`; the sibling site in `SearchStateFeeder` already did this.++**4. Overclaiming comments.** Three comment blocks said the scanners "can never+disagree" / "stay in lockstep". Reworded to what is true: the shared predicate settles+ESCAPING at four sites, and the residuals below remain.++### Known residuals++Recorded rather than fixed, and none of them regressions:++- **Comment excision can fabricate an escape (new, narrow).**+  `HTMLCommentStripping.removing` excises a span leaving no separator, so+  `A\<!-- x -->[^1]` collapses to `A\[^1]` in `scanSource` and reads as escaped, while+  the renderer badges it — cmark treats `\<` as an escaped `<`, so no comment ever+  opened. The mis-detection is upstream in the comment scan; inserting a separator+  would change the searchable text of every commented block, which is a wider change+  than this ticket.+- **`badgeEligibility` scans the raw source, `referencedFootnoteIdentifiers` the+  comment-gated `scanSource`** (pre-existing). For a block containing an HTML comment+  the two iterate different text, so the arrays can still shift relative to each+  other. `currentMatch` degrades safely — a missing flag is read as eligible.+- **The preprocessor's scan is line-at-a-time**, so it cannot see an indented code+  block or a code span that spans lines, and its closing-run match does not require+  equal run lengths (pre-existing).+- **The reserved Private Use Area window** the renderer marks references with is not+  something the preprocessor models at all (pre-existing, by design).+- **`FootnoteStripping.strip` and `DocumentReaderView.routeDocumentLink`** do not+  consult the predicate (sites 5 and 6 above).++## Verification++**Automated:**+- [x] Regression tests pass (`FootnotePreprocessorTests`, `MarkdownBlockSearchContextTests`,+      `WebSearchBridgeTests`)+- [x] Mutation-checked (PR #416 round): with the escape filter removed from BOTH+      `SearchStateFeeder` sites, `escapedReferenceKeepsFeederArraysAligned` and+      `escapedReferenceOrdinalsWalkTheLiveOccurrences` fail and+      `escapedReferenceRendersNoBadge` still passes — the last asserts the renderer's+      own behaviour, which the mutation does not touch, so it is the premise rather+      than a detector. Result bundle read by `Tools/check-test-results.sh`:+      `total=3 passed=1 failed=2`.+- [!] Machine-state caveat for the final verification pass: after the first run of the+      review round, every subsequent `xcodebuild test` on this machine wedged during+      TEARDOWN — tests completed (the log records each one), then the process slept+      indefinitely without finalising its result bundle, so+      `Tools/check-test-results.sh` could not read it. This is the wedge recorded in+      the project's testmanagerd note, not a property of the change; killing+      `testmanagerd` did not clear it, and by the last attempt the run hung before any+      test executed at all. Evidence actually collected: one checker-verified bundle+      (`total=108 passed=107 failed=1` — the single failure being an incorrect+      `cleanedSource` expectation in a new test, since corrected), the+      checker-verified mutation bundle above, and four further log-only runs recording+      422 / 190 / 189 / 189 executions with zero failures, the 422-execution run+      covering ten footnote suites. The corrected assertion is confirmed by those+      log-only runs rather than by a bundle.+- [x] Adjacent suites pass (`FootnotePreprocessorHTMLBlockExtentTests`,+      `FootnotePreprocessorBlockExtentParityTests` — unaffected by this change, run to+      confirm no disturbance from the T-1963 rework's neighbourhood;+      `FootnoteBadgeSubstitutionTests`, `FootnoteBadgeOrderingTests`,+      `FootnoteBadgeNestedContextTests`, `FootnoteNonTextOccurrenceTests` — the+      renderer-side escape suite, confirming component #2 needed no change)+- [x] `make lint` passes+- [x] `make build-macos` passes+- [ ] Full `make test-quick` / `make test-locales` — intentionally NOT run per task+      instructions (concurrent sibling builds); targeted suites above were run instead++**Manual verification:** Traced the call sites by hand against+`FootnoteReferenceScanner.scan`'s documented escape rule to confirm the shared+predicate's parity check produces the same live/escaped classification for `\[^1]`+(escaped) and `\\[^1]` (not escaped, since the pair of backslashes escapes itself).++## Prevention++**Recommendations to avoid similar bugs:**+- When a new predicate is added to one of several components that must agree on+  parsing a shared syntax (here, `[^id]`), centralise it next to the shared pattern+  (`FootnoteData.referencePattern`) rather than leaving it local to the component+  that first needed it — this is the same shape T-1963 named for HTML-block extent+  (preprocessor vs. real parser) and now for reference escaping (preprocessor vs.+  renderer vs. search).+- The ticket also asked to verify two documentation claims: a code comment in+  `InlineHTMLRenderer` and CLAUDE.md's Footnote System section were checked and no+  longer contain any statement that badge substitution "agrees with+  scanLineForReferences" — both were already cleaned up by the T-1963/T-1716+  rewrites before this ticket, so no correction was needed there.++## Related++- T-1716 (introduced `FootnoteReferenceScanner.scan`'s escape-aware badge substitution)+- T-1963 (footnote preprocessor HTML-block extent parity, most recent prior rework of+  this file)+- T-1853 (footnote search current-match occurrence addressing, whose "occupies+  ordinal space" comments this fix corrects for the escape case)
specs/footnotes/decision_log.md Modified +7 / -0
diff --git a/specs/footnotes/decision_log.md b/specs/footnotes/decision_log.mdindex 731b7c8d..b4a85831 100644--- a/specs/footnotes/decision_log.md+++ b/specs/footnotes/decision_log.md@@ -1,5 +1,12 @@ # Decision Log: Footnotes +## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-06 | A `[^id]` occurrence is escaped when an odd-length run of backslashes immediately precedes it, decided by one shared `FootnoteData.isEscaped(in:before:)` used by every scanner outside the renderer | Restates `FootnoteReferenceScanner.scan`'s existing left-to-right rule (T-1716) rather than inventing a second one; parity is equivalent because a `Regex` match reports only where a token starts. No genuine alternative — a per-site copy is the bug (T-1968) |+| Q2 | 2026-09-06 | The predicate is named for the character, not for `[^id]`, and the preprocessor's code-span skip applies it to a backtick — the opener only, never the closing run | CommonMark escapes a code-span-opening backtick by the same rule, but backslash escapes do not apply inside a code span (`` `foo\`bar` `` is `<code>foo\</code>` plus literal text). A reference-shaped name would have invited a second copy of the parity walk (T-1968 review) |+ ## Decision 1: Feature Name  **Date**: 2026-03-31
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex f75bfbce..31a564f4 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -135,6 +135,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - On the Mac, opening a document while another one is already open now closes the image and diagram windows belonging to the document you left, instead of stranding them (T-1757). Those windows were only ever cleared away by closing a document explicitly; opening a replacement — from the file picker, Recent Files, a paste, the bundled guides, or a URL — left them on screen belonging to a document that was no longer open, with no way left to clear them short of closing each one by hand. Reopening the document you are already reading is not a replacement and leaves its windows exactly where they are, so picking the current document again from Recent Files still costs you nothing; refreshing a URL document and reloading a file changed on disk keep those windows too, as they always have. Two pasted documents are never treated as the same document, so pasting fresh content still clears the previous paste's windows. - HTML comments embedded in paragraph text (and in headings, list items, and table cells) render again as dimmed inline annotations when "Show HTML comments" is on — they were silently dropped by the new rendering engine (T-1638). Stand-alone comments nested inside blockquotes or list items now get the same toggleable annotation treatment instead of leaking as always-visible plain text, and comment annotations regained their info indicator glyph. Search now stays aligned with what is actually rendered: comment shapes that are never displayed (conditional comments, note-infrastructure tags) no longer count as matches when the toggle is on, and hidden comment text nested inside blockquotes or list items no longer produces phantom highlights. - A note written while a document's notes are still loading is no longer thrown away when the load finishes (T-2089). Notes can be added the moment a document appears, but loading them from iCloud takes a moment longer — and a note added in that gap vanished from the screen as soon as the load landed, because the load put back the set of notes it had read before your note existed. Where that load also had to re-attach notes to moved text, it saved that older set back to iCloud too, so the note was gone for good rather than just off-screen until the next reopen. The load now recognises when the notes have moved on beneath it and keeps what is on screen — your note and everything already stored — instead of replacing it. A reload with nothing added still picks up whatever iCloud holds, as before.+- A backslash-escaped footnote reference (`\[^1]`) no longer consumes a display number or keeps an otherwise-orphaned definition alive (T-1968). The footnote preprocessor's reference scan counted `\[^1]` as a real reference even though it renders as literal text — a footnote it kept alive that way pushed every genuinely-referenced footnote's display number one higher, and the renderer already deliberately declined to badge it, so the two disagreed on what counted as a reference. The scan is now escape-aware the same way the renderer is: a reference preceded by an odd-length run of backslashes is literal, an even-length run (including zero) is live. Search shared the same blind spot — a block's searchable text and its footnote-badge indication both scanned raw source for `[^id]` with no escape awareness, so an escaped occurrence sitting alongside a live reference to the same footnote could pull that footnote's content into search a second time — and now shares the same escape check.+- An escaped backtick no longer hides the footnote reference that follows it (T-1968). The preprocessor's code-span skip treated every backtick as a code-span delimiter, so in a line like `` \`[^1]` `` — an escaped backtick, a live reference, then a stray literal backtick — it paired the two backticks up and read the reference as code, dropping the footnote from the document's numbering and orphaning its definition. The renderer, which parses the line properly, showed the badge, so the footnote appeared with no number behind it. A backslash-escaped backtick is now literal text and opens nothing, matching CommonMark and matching what the renderer already did. Only the opening backtick is treated this way: backslashes have no escaping power inside a code span, so a backtick that closes one still closes it.  ### Security 

Things to double-check

Nothing was changed in the worktree.

This review was explicitly read-only. git status --porcelain is empty at the end, exactly as at the start; every verification command ran against a git archive export of HEAD outside the repository. Every finding above is therefore a follow-up list, not a record of applied fixes.

Merge against the moved origin/main is clean.

Main advanced to 0ba0501b (PR #415) after the merge-base 118ef1da. git merge-tree --write-tree origin/main HEAD produced a tree with no conflict report, so the anticipated CHANGELOG add/add overlap does not materialise. The review diff is 118ef1da...HEAD, i.e. only what this branch adds.

The bugfix report's testmanagerd caveat did not reproduce here.

The report records that every xcodebuild test after the first wedged during teardown, so the final verification rests partly on log-only runs. That did not happen for this review: a single targeted run with -enableCodeCoverage NO finalised its bundle and Tools/check-test-results.sh read total=136 passed=136 failed=0 across FootnotePreprocessorTests, MarkdownBlockSearchContextTests, WebSearchBridgeTests, FootnoteBadgeSubstitutionTests and FootnoteBadgeOrderingTests. Worth noting on the ticket — the coverage flag appears to be the difference.

verify-test-isolation: the new synchronous tests are legitimately fine.

Three new tests in WebSearchBridgeTests are synchronous inside a @Suite(.liveWebKit) @MainActor suite, and two new tests in the unannotated FootnotePreprocessorTests struct now call FootnoteRenderProbe.emit. Neither reaches WebKit: the probe goes through BlockHTMLEmitter, and none of SearchStateFeeder, SearchService or InlineHTMLRenderer is in PRODUCTION_WEBKIT_TYPES. I ran the guard rather than reasoning about it — it passes, along with its 83 unit tests. Forward risk worth knowing: this holds only while BlockHTMLEmitter stays off that list.

Full suite not run.

Per the review brief, one targeted xcodebuild run only. make test-quick, make test, make test-ui and make test-locales were not run. The change is four small guards in pure functions with no signature changes, and make build-macos is recorded as passing in the bugfix report, so the blast radius outside the suites exercised is small — but a full make test before merge is still the project's stated pre-push bar. CI runs no tests, so a green PR says nothing here.