PR #422 — SearchStateFeeder.badgeEligibility now scans the same comment-gated scanSource as referencedFootnoteIdentifiers, so the two arrays currentMatch consumes as index-aligned can no longer drift in length or order. Reviewed against merge-base 16cf95ae.
SearchStateFeeder plus the footnote/comment/search neighbours. make lint clean (0 violations, 580 files); make verify-test-isolation passes.visitInlineHTML escapes the body verbatim, and badgeSourceStarts(source:footnotes:) takes no RenderSettings, so the toggle is not even an input to badge placement. It does not hold for a span only HTMLCommentStripping recognises.prism/Models/MarkdownBlock.swift:1027-1030 still says the two functions "differ on comment gating, because badgeEligibility scans the raw source instead (pre-existing)". That is the bug this PR fixed.SearchStateFeeder.swift:334-338 ("never becomes a badge regardless of whether the comment itself is CSS-visible"), which states an invariant the code does not have.Use `<!--` to open[^1] and `-->` to close.), and a comment body ending in - (<!-- x --->), which cmark's grammar at swift-cmark/src/scanners.re:57 cannot derive. Nothing in the repo pins the two grammars against each other.showHTMLComments: true test. All four new tests use the default-off context, so the else branch the PR adds is never executed by them.Ready to merge — correct two doc claims first
The fix is correct and it is the right shape. Both feeder arrays now derive from one scan (scanSource) with an identical filter chain — same regex (FootnoteData.referencePattern, shared by all three consumers), same isEscaped predicate, same resolvability test — so the length/order drift T-1968 documented and left standing is genuinely gone. The new rawOffset(forBaseOffset:matches:) arithmetic is correct at every boundary I checked, its units are UTF-16 end to end, and the excision equivalence it silently depends on (removing(matches(in:t), from:t) == stripHTMLComments(t), which is what the default toggle-OFF path exercises) is asserted per-iteration inside a 25,000-case differential fuzz. 177 targeted tests pass; lint and the isolation guard are clean.
No blocking findings, and nothing that worked before is broken. But the review question was whether this closes the drift class or only the reported instance, and the answer is: only the half that lives inside SearchStateFeeder. Two independent lines of investigation — reading cmark's vendored grammar, and a compiled probe over the project's own swift-markdown build — converged on the same remaining root cause: HTMLCommentStripping and cmark do not agree on what an HTML comment is. The app's scanner takes <!-- to the first --> anywhere in the string; cmark applies a stricter grammar, and only at a < that is not inside a code span or a link destination. Where they disagree, a [^id] the feeder believes is inside a comment is in fact ordinary text that renders a badge — and the wrong badge gets marked, which is the T-2025 symptom by a different mechanism.
That residual is pre-existing and out of scope for #422: on the default (comments-off) path the pre-fix code produced the same wrong badge. It matters here only because the PR's doc comment states the opposite as an absolute invariant ("a footnote reference inside any comment — hidden or visible — never renders a badge"), and this codebase treats those comments as the specification. Qualify that claim, and fix the stale mirror in MarkdownBlock.swift that still says this bug is unfixed, then merge.
c887bdf9 Fix T-2025: Hidden Comments Skew Repeated-Footnote Search Badge Occurrence When you search a document, Prism can jump between footnote markers — the little numbered pills next to text. To know which pill to highlight, the app builds two lists side by side: one listing every footnote reference the search found, and one saying, for each of those, whether it actually shows up as a pill on screen. The app then reads them together, position by position, like two columns of the same table.
The trouble was that the two lists were built by reading two different versions of the text. One read the text with HTML comments stripped out (comments are hidden by default). The other read the raw text, comments and all. So if you wrote a footnote reference inside a comment, the second list gained an extra row the first list didn't have — and from that row onwards the two columns no longer lined up. Every footnote after the hidden one answered with the row above it.
Searching for something that appears in a footnote, then pressing "next", highlighted the wrong pill: the first one again, instead of the second. Nothing was lost or corrupted — it just pointed at the wrong place, which is confusing when you are using search to hop between repeated references.
SearchStateFeeder translates the native search model into the payload the WebKit page renders. Two of its products are consumed as index-aligned by currentMatch:
footnoteMatchCounts → built from referencedFootnoteIdentifiers, which scans MarkdownBlock.footnoteScanComponents(...).scanSource — the inline source with every <!--…--> span excised, joined with the bodies of any comments the renderer actually surfaces.badgeEligibility → a [Bool] saying which of those occurrences render as a DOM badge. It scanned the raw inline source.currentMatch computes a badge's DOM occurrence as "count of prior same-id entries that are eligible", indexing straight into the flags array with the entry index. Two arrays, two scans, no enforcement.
badgeEligibility now iterates the same scanSource. Because badgeSourceStarts is deliberately keyed in raw source coordinates (that is what the renderer parsed), a base-portion match must be rebased before the lookup — hence the new rawOffset(forBaseOffset:matches:), which walks the comment match list once and adds back the excised lengths. A match landing in a surfaced comment body is flagged false outright, with no lookup at all.
The report considered two alternatives and rejected both: making both functions scan raw (changes relative order for the toggle-on case, where scanSource deliberately puts base text first and comment bodies after), and translating the index at the currentMatch call site (the point-of-use translation pattern that already failed once, in T-1968). Choosing the single coordinate space is the right call, and it is the same lesson the codebase learned in T-1992/T-2033.
The cost is a small amount of duplicated filter logic: the where-clause resolvability test and the isEscaped guard are now spelled out identically in two functions. Alignment is maintained by that duplication, not by a shared helper.
Both loops call the same pure inlineSources(of:) over the same block, and the same pure footnoteScanComponents(forInlineText:context:) over the same (source, context). Filter composition differs only in order — {¬escaped} ∩ {resolvable} versus {resolvable} ∩ {¬escaped} — and conjunction commutes while sequence filtering preserves relative order, so the surviving occurrence sets and their orders are identical. FootnoteData.isEscaped reads only the backslash run preceding the index, i.e. it is a pure function of the string prefix, so evaluating it against two == but distinct String instances is safe. Both sites hoist the computed scanSource into a local before calling matches(of:), which is load-bearing: a String.Index from one instance handed to another is a programmer error that happens to work only while the instances compare equal. The sibling site in MarkdownBlock.swift documents that trap explicitly, and this PR does not reintroduce it.
rawOffset and the coordinate spacesCorrect at every boundary. Segments are S₀=[0, m₀.loc), Sᵢ=[mᵢ₋₁.end, mᵢ.loc), Sₙ=[mₙ₋₁.end, len). An offset exactly at a segment boundary fails the strict <, advances, and returns the raw offset of the first character after the removed span — which is precisely the character at that base offset. Zero-length segments (adjacent comments) are skipped correctly. An empty match list degenerates to the identity, which is the common case and costs one branch. An unterminated <!-- is reported by neither scanner, so both sides retain the tail verbatim and identity mapping is right.
Units are consistent: NSRange.location/.length are UTF-16, scanOffset is scanSource.utf16.distance(...), baseLength is .utf16.count, and badgeStarts come from FootnoteReferenceScanner.Occurrence.sourceStart, an index into Array(source.utf16). UTF-16 code-unit counts are exactly additive across Swift string concatenation regardless of grapheme boundaries, so baseLength partitions scanSource soundly.
scanOffset < baseLength partitionUnreachable as an equality and immune to straddling. The join separator is U+0020; the pattern is /\[\^([a-zA-Z0-9_-]+)\]/, whose character class excludes space and whose ] must follow the identifier directly — so no match can contain the separator, and every match lies wholly inside the base or wholly inside one body. With an empty base the leading separator still emits, so every match has scanOffset ≥ 1 > 0.
badgeEligibility computes HTMLCommentStripping.matches(in: source) unconditionally, but footnoteScanComponents builds baseWithoutMarkers from HTMLCommentStripping.removing only when the toggle is ON — when OFF (the default) it uses HTMLImageParser.stripHTMLComments, a separate implementation. rawOffset's correctness on the default path therefore rests on those two agreeing. They do, and it is asserted rather than assumed: HTMLCommentStrippingTests.fuzzRandomFragments checks removing(matches(in: t), from: t) == stripHTMLComments(t) on every one of 25,000 random fragments drawn from a hostile alphabet (delimiter fragments, combining marks, surrogate pairs, ZWJ sequences). This is a genuinely well-pinned dependency, but it is now a third consumer of that identity and it is not named at the new call site.
The winner's own eligibility flag is computed and never read — currentMatch counts only (0..<matchIndex) — so an ineligible winner still emits .badge(id:, occurrence: N) addressing some other badge. That is deliberate and pre-existing (T-1853), and prism-search.js's resolveCurrentBadge clamps occurrence into the badge list rather than resetting to zero. The clamp is worth knowing about when reading the new live test: it means a feeder-level misalignment can be partially masked in the DOM, which is why the two feeder-level assertions (matchedFootnoteIds == ["1", "1"] and .badge(id: "1", occurrence: 1)) carry more of the regression weight than the live-DOM one.
Toggle-ON, traced by hand for <!-- [^1] --> First[^1] Second[^1]: scanSource orders the two base occurrences first, then the comment body's, giving flags [true, true, false]. Live ordinals 0 and 1 resolve to occurrences 0 and 1 — correct, and a fix relative to the old [false, true, true], which gave the second live reference occurrence 0. The comment body's own ordinal resolves to occurrence 2, clamped by the JS to the section's last badge. So the toggle-ON path is fixed by the same change, and the only behavioural difference on the comment's own ordinal is which wrong-but-harmless badge it lands on. That is the untested branch, and it is untested rather than broken.
prism/Services/SearchStateFeeder.swift
Why it matters. This is the whole fix. It removes the second, independent scan whose existence was the defect — both index-aligned arrays now come from one text with one filter chain, so they cannot differ in length or order.
What to look at. SearchStateFeeder.swift:360-396 (badgeEligibility)
prism/Services/SearchStateFeeder.swift
Why it matters. badgeSourceStarts is deliberately keyed in raw-source coordinates, so scanning scanSource means the lookup key has to be translated back. This helper is the only new logic in the change and the only place a new arithmetic bug could hide.
What to look at. SearchStateFeeder.swift:398-426
prism/Services/SearchStateFeeder.swift
Why it matters. This is the branch the PR's premise rests on, and the branch none of the new tests execute. If a footnote reference inside a visible comment could ever badge, this would be wrong in exactly the way the PR is trying to fix.
What to look at. SearchStateFeeder.swift:389-391
prism/Services/SearchStateFeeder.swift
Why it matters. The old badgeEligibility doc comment described this residual as permanent. It is now correctly rewritten, and referencedFootnoteIdentifiers' comment updated to match. The same statement in MarkdownBlock.swift:1027-1030 was not.
What to look at. SearchStateFeeder.swift:307-345, 255-259; missed mirror at MarkdownBlock.swift:1027-1030
prismTests/WebRendering/WebSearchBridgeTests.swift
Why it matters. The feeder-level assertions are the ones that actually pin the bug. The live-DOM test is valuable as a wiring check but is weakened by prism-search.js clamping an out-of-range occurrence into the badge list, which can mask a misalignment.
What to look at. WebSearchBridgeTests.swift:707-822
scanSource — already treated as authoritative for "which occurrences count" by referencedFootnoteIdentifiers and combinedSearchableText — becomes authoritative for badgeEligibility too. Offsets are rebased only when a raw-keyed lookup actually needs it, which is the narrow, well-defined part.
This is the correct read of the T-1968/T-1992/T-2033 history: each of those shipped a fix at the point of use and left a sibling shape standing. Moving the invariant upstream is what breaks that pattern.
Rejected in the report because scanSource deliberately orders base text first and surfaced comment bodies after, whereas raw source interleaves them. Switching to raw would change the relative order of occurrences for a toggle-on block mixing base and comment references, risking a regression in currentMatch's overflow walk. Correct call — and note the alternative would have had to re-derive the comment-visibility gate as well.
Rejected as the pattern that already failed once. Worth stating the general form: a translation is a second place the invariant has to be restated, and the two places drift independently. This is the same reasoning that made DocumentSession.notesRecordURL the origin of a move migration rather than move.from.
The report calls misalignment "structurally impossible" and "no longer possible by construction". That is overstated. Nothing mechanical enforces it: the four-step chain (same inlineSources, same scanSource, same isEscaped, same resolvability test) is spelled out separately in each function, and no test asserts footnoteMatchCounts(...).count == badgeEligibility(...).count over a corpus. A shared private occurrences(of:context:) -> [(identifier: String, scanOffset: Int, inBase: Bool)] consumed by both would earn the claim. Not a defect today — the two chains do match — but the phrase invites a future edit to one side only.
ExportSourceMapper.rawOffset(from:tags:) does the same translation with a different algorithm (reverse-order accumulation with a <= boundary) over a different span type. The two should not be unified: ExportSourceMapper works in Character offsets and the new helper in UTF-16, and a shared helper that blurred that distinction would be a bug factory. A cross-reference in the doc comment is the right amount of coupling.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | prism/Models/MarkdownBlock.swift:1027-1030 | Stale doc comment. It still states that referencedFootnoteIdentifiers and badgeEligibility "differ on comment gating, because SearchStateFeeder.badgeEligibility scans the raw source instead (pre-existing, noted at that call site)". That is precisely the divergence this PR removes. The SearchStateFeeder side of the mirror was updated; this side was not. In a codebase where doc comments are the primary architecture record, a comment asserting a fixed bug is still open will send the next reader to re-investigate T-2025 — or to "restore" an alignment that already holds. | Update those four lines to say both functions now scan the same scanSource (T-2025), keeping the T-1968 escaping sentence. Not applied here: this was a read-only review. |
| major | prism/Services/SearchStateFeeder.swift:334-338 (doc comment) | The doc comment states an invariant the code does not have: "a `[^id]` inside a comment never becomes a badge regardless of whether the comment itself is CSS-visible". True of comments cmark recognises; false of spans only HTMLCommentStripping recognises. visitInlineHTML (InlineHTMLRenderer.swift:596-622) only runs on nodes cmark already classified as InlineHTML, whereas footnoteScanComponents classifies via HTMLCommentStripping.matches, which scans for a literal `<!--` and the first `-->` with no awareness of code spans, link destinations, or cmark's grammar. Worked example: `Use `<!--` to open[^1] and `-->` to close. See[^1] again.` — the delimiters are inside code spans, so cmark badges the first [^1]; the app's scanner excises the whole span, so the feeder never sees that occurrence and marks badges[0] (the pseudo-comment's badge) when the reader navigates to the second reference. | Qualify the wording — say that a span cmark parses as an InlineHTML comment never badges, and that the classification here is HTMLCommentStripping's, which is a superset. Do NOT change the code: the false flag is not a regression (see the note below) and the real fix belongs in a follow-up. Not applied here: read-only review. |
| minor | prismTests/WebRendering/WebSearchBridgeTests.swift (new tests) | No showHTMLComments: true coverage. All four new tests use footnoteContext(), which is showHTMLComments: false, so the else branch this PR introduces (flags.append(false) for a surfaced comment body, SearchStateFeeder.swift:389-391) is never executed by them — nor is any case where baseWithoutMarkers is non-empty AND surfacedCommentBodies is non-empty, which is the only situation in which the new baseLength partition does any work. The PR's own report acknowledges this as a narrow uncovered scenario. | Acceptable to merge. It is a gap in coverage, not in correctness for the shapes the PR targets — I traced `<!-- [^1] --> First[^1] Second[^1]` with the toggle ON by hand and the fix is right there too (flags [true, true, false]; live ordinals resolve to occurrences 0 and 1, versus [false, true, true] and a wrong occurrence 0 before). One test mirroring hiddenCommentReferenceKeepsFeederArraysAligned with SearchContext(showHTMLComments: true, ...) closes it in about ten lines. Note that adding it would NOT have surfaced the doc-comment issue above, which needs a pseudo-comment shape, not a toggle flip. Not applied here: read-only review. |
| minor | specs/bugfixes/footnote-badge-occurrence-drift/report.md (Resolution) | "Misalignment is no longer possible by construction" / "structurally impossible" overstates what the code guarantees. The alignment is real but it is maintained by duplicating a four-step predicate chain across two functions, with no shared helper and no test asserting the two arrays are the same length over a corpus. | Either soften the wording, or extract a shared private occurrences(of:context:) helper that both functions consume — which would make the claim literally true. The helper is the better fix and is a small refactor; it can be a follow-up. |
| info | prism/Services/SearchStateFeeder.swift:371 | HTMLCommentStripping.matches(in: source) is computed unconditionally for every inline source, including the overwhelmingly common comment-free case and the case where every match takes the base branch with an empty match list. | Not worth changing. The scan is linear, badgeEligibility is invoked lazily for at most one block per search push (currentMatch:456), and the helper degenerates to the identity in one branch when the list is empty. Noted only so it is not mistaken for an oversight. |
| info | prism/Services/SearchStateFeeder.swift:398 vs prism/Services/ExportSourceMapper.swift:171 | Two stripped→raw offset mappers now exist with the same purpose and different implementations (forward walk over NSRange spans in UTF-16; reverse-order accumulation over StrippedTag in Character offsets). | Deliberately do not unify — the coordinate units differ and merging them would invite a units bug. A one-line cross-reference in the new helper's doc comment is enough, and the new implementation is the clearer of the two. |
Source: local run at 2026-09-07T03:30:00+10:00 · snapshot c887bdf9f1dab1bfe9795afb1b8a3113265dbd24
Baseline: none
Execution: passed · JUnit: none · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
The test runner could not be detected.
Derived by declaration name, from the diff (no baseline run).
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 16cf95ae.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Services/SearchStateFeeder.swift b/prism/Services/SearchStateFeeder.swiftindex 441dcf0d..2d1be2f7 100644--- a/prism/Services/SearchStateFeeder.swift+++ b/prism/Services/SearchStateFeeder.swift@@ -255,8 +255,8 @@ enum SearchStateFeeder { // 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`.+ // arrays out of alignment (T-1968). `badgeEligibility` scans this+ // same `scanSource` too (T-2025), so comment gating can't either. guard !FootnoteData.isEscaped(in: scanSource, before: match.range.lowerBound) else { continue }@@ -304,29 +304,46 @@ enum SearchStateFeeder { return sources } - /// 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.+ /// Badge-eligibility flags aligned one-to-one with `footnoteMatchCounts`'s+ /// entries: same inline-source iteration, same comment-gated `scanSource`+ /// (`MarkdownBlock.footnoteScanComponents`) `referencedFootnoteIdentifiers`+ /// scans, same resolvable-identifier filter, and the same escape predicate. 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`.+ /// Before T-2025 this scanned the RAW inline source instead of `scanSource`,+ /// so a reference hidden inside a comment (or excluded because its enclosing+ /// comment's shape is rejected) still contributed a flag here with nothing to+ /// pair it with in `footnoteMatchCounts` — the array picked up an extra+ /// leading/interior entry and every flag after it landed one position off.+ /// `currentMatch` has no way to detect that shift; it just reads the wrong+ /// flag and reports the wrong occurrence. Scanning the same `scanSource` both+ /// functions already share, rather than translating indices between two+ /// independently-filtered arrays, makes that class of drift structurally+ /// impossible: whatever `referencedFootnoteIdentifiers` counts is exactly what+ /// this counts too. ///- /// 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, link nesting) gets `false`; an escaped occurrence never reaches- /// this array at all (excluded upstream, same as `referencedFootnoteIdentifiers`).+ /// `scanSource` is comment-gated text, not what the emitter actually rendered+ /// from, so an occurrence's offset there cannot be looked up directly against+ /// `badgeStarts` (`EmittedDocument.badgeSourceStarts`, keyed in RAW source+ /// coordinates — see the note on `BlockHTMLEmitter.badgeSourceStarts`). Two+ /// cases:+ /// - An occurrence in the BASE portion (`components.baseWithoutMarkers`, i.e.+ /// outside every comment span) is rebased to its raw offset via+ /// `rawOffset(forBaseOffset:matches:)` and looked up normally.+ /// - An occurrence in a SURFACED comment body (toggle on, accepted shape) is+ /// always `false`, not looked up at all: `InlineHTMLRenderer.visitInlineHTML`+ /// escapes comment text verbatim rather than re-parsing it for markdown, so+ /// a `[^id]` inside a comment never becomes a badge regardless of whether+ /// the comment itself is CSS-visible.+ ///+ /// Eligibility for a base-portion occurrence comes from the render pipeline+ /// itself (`FootnoteReferenceScanner` candidacy plus the walker's position+ /// rules), matched back 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, 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@@ -350,22 +367,64 @@ enum SearchStateFeeder { let starts = cachedStarts(source) ?? InlineHTMLRenderer.badgeSourceStarts(source: source, footnotes: context.footnoteData) let badgeStarts = Set(starts)- for match in source.matches(of: FootnoteData.referencePattern)++ let commentMatches = HTMLCommentStripping.matches(in: source)+ let components = MarkdownBlock.footnoteScanComponents(forInlineText: source, context: context)+ let scanSource = components.scanSource+ let baseLength = components.baseWithoutMarkers.utf16.count++ for match in scanSource.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 {+ guard !FootnoteData.isEscaped(in: scanSource, before: match.range.lowerBound) else { continue }- let offset = source.utf16.distance(from: source.startIndex, to: match.range.lowerBound)- flags.append(badgeStarts.contains(offset))+ let scanOffset = scanSource.utf16.distance(from: scanSource.startIndex, to: match.range.lowerBound)+ if scanOffset < baseLength {+ let offset = rawOffset(forBaseOffset: scanOffset, matches: commentMatches)+ flags.append(badgeStarts.contains(offset))+ } else {+ // A surfaced comment body: never a badge, see the doc comment above.+ flags.append(false)+ } } } return flags } + /// Maps a UTF-16 offset within `baseWithoutMarkers` — the text+ /// `MarkdownBlock.footnoteScanComponents` returns after removing every+ /// `<!--…-->` span in `matches` from the original inline source — back to+ /// that span's offset in the ORIGINAL source. `matches` must be+ /// `HTMLCommentStripping.matches(in:)` computed from that same source.+ ///+ /// `matches` are non-overlapping and in increasing order (the same contract+ /// `HTMLCommentStripping.removing` relies on): walk them once, and for each+ /// one either `baseOffset` falls in the untouched span just before it — in+ /// which case the raw offset is that span's raw start plus the same+ /// within-span delta — or it doesn't, in which case skip past the removed+ /// span and keep looking. A `baseOffset` past every match falls in the+ /// trailing untouched span after the last one.+ private static func rawOffset(+ forBaseOffset baseOffset: Int,+ matches: [HTMLCommentStripping.CommentMatch]+ ) -> Int {+ var rawCursor = 0+ var baseCursor = 0+ for match in matches {+ let segmentLength = match.range.location - rawCursor+ if baseOffset < baseCursor + segmentLength {+ return rawCursor + (baseOffset - baseCursor)+ }+ baseCursor += segmentLength+ rawCursor = match.range.location + match.range.length+ }+ return rawCursor + (baseOffset - baseCursor)+ }+ /// Resolves the current within-block match index into a text ordinal or a /// footnote badge id. The within-block ordinal space is [text matches…][footnote /// matches in source order], matching `combinedSearchableText`'s concatenation.
diff --git a/prismTests/WebRendering/WebSearchBridgeTests.swift b/prismTests/WebRendering/WebSearchBridgeTests.swiftindex 71abc446..3f8a474f 100644--- a/prismTests/WebRendering/WebSearchBridgeTests.swift+++ b/prismTests/WebRendering/WebSearchBridgeTests.swift@@ -704,6 +704,123 @@ struct WebSearchBridgeTests { #expect(starts == [29, 43]) } + // MARK: - T-2025: a hidden comment must not skew the badge occurrence++ /// The source used by the hidden-comment-alignment tests below. Literally:+ /// `See <!-- [^1] --> First[^1] Second[^1]`+ ///+ /// A minimal non-comment prefix keeps this a `.paragraph` with an INLINE HTML+ /// comment (a line starting with `<!--` parses as a block-level HTML comment+ /// instead, per CommonMark — a different block variant this bug does not+ /// reach). One `[^1]` hidden inside that leading comment, followed by two live+ /// references to the same footnote. With comments hidden (the default),+ /// `referencedFootnoteIdentifiers` counts only the two live occurrences — so+ /// the block's footnote ordinal space is exactly two entries — but+ /// `badgeEligibility` used to scan the RAW source independently, producing a+ /// THIRD flag (for the hidden occurrence) at index 0. `currentMatch` then read+ /// that flag when resolving the second live occurrence, computing badge+ /// occurrence 0 instead of 1 (T-2025).+ private static let hiddenCommentThenLiveSource =+ "See <!-- [^1] --> First[^1] Second[^1]"++ @Test("A footnote reference hidden in a comment is excluded from both feeder arrays, keeping them aligned")+ func hiddenCommentReferenceKeepsFeederArraysAligned() {+ // `referencedFootnoteIdentifiers` and `badgeEligibility` are private, so — as+ // with the T-1968 escape tests above — the alignment is observed through the+ // payload. `matchedFootnoteIds == ["1", "1"]` pins that the hidden occurrence+ // contributes no third entry; `current == .badge(id: "1", occurrence: 1)` pins+ // that `badgeEligibility`'s flags line up one-to-one with those two entries+ // rather than carrying a leading flag for the hidden occurrence.+ let blocks: [MarkdownBlock] = [.paragraph(markdown: Self.hiddenCommentThenLiveSource)]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // 2 footnote-content matches, one per LIVE reference; the hidden one+ // contributes nothing (comments hidden by default).+ #expect(counts[0] == 2)++ let states = SearchStateFeeder.buildStates(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ #expect(states.count == 1)+ #expect(states.first?.textMatchCount == 0)+ #expect(states.first?.matchedFootnoteIds == ["1", "1"])+ #expect(states.first?.current == .badge(id: "1", occurrence: 1))+ }++ @Test("Match ordinals across a hidden comment reference address the two live badges")+ func hiddenCommentOrdinalsWalkTheLiveOccurrences() {+ // The whole ordinal walk over the same source: index 0 is the first live+ // reference's badge, index 1 the second. Nothing addresses the hidden+ // occurrence, because it never entered the ordinal space.+ let blocks: [MarkdownBlock] = [.paragraph(markdown: Self.hiddenCommentThenLiveSource)]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }++ let expected: [SearchStateFeeder.CurrentMatch] = [+ .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("A footnote reference hidden in a comment renders no badge, regardless of the toggle")+ func hiddenCommentReferenceRendersNoBadge() {+ // The premise the two tests above rest on: an inline HTML comment is always+ // escaped as literal text (InlineHTMLRenderer.visitInlineHTML) rather than+ // re-parsed for markdown, so a `[^1]` inside one never becomes a badge —+ // whether or not the comment itself is CSS-visible.+ let starts = InlineHTMLRenderer.badgeSourceStarts(+ source: Self.hiddenCommentThenLiveSource, footnotes: footnoteData()+ )+ #expect(starts.count == 2)+ }++ @Test("Live: current badge on the second visible reference after a hidden comment")+ func liveCurrentBadgeAfterHiddenCommentReference() async throws {+ let blocks: [MarkdownBlock] = [.paragraph(markdown: Self.hiddenCommentThenLiveSource)]+ let footnotes = footnoteData()+ let context = footnoteContext()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ footnotes: footnotes,+ featureScripts: ["prism-search"]+ )+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // Global index 1 → the SECOND live reference — the section's second (and+ // last) real badge, since the hidden comment's occurrence never badges.+ let json = SearchStateFeeder.searchStateJSON(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ try await harness.send(.setSearchState(json: json, reveal: false))+ try await Task.sleep(for: .milliseconds(150))++ let attributes = try await harness.evalString(+ "var badges = document.querySelectorAll('[data-prism-footnote=\\\"1\\\"]');"+ + " if (badges.length !== 2) { return 'count:' + badges.length; }"+ + " function d(b) { return (b.getAttribute('data-prism-search-match') || '-')"+ + " + '/' + (b.getAttribute('data-prism-search-current') || '-'); }"+ + " return d(badges[0]) + '|' + d(badges[1]);"+ )+ #expect(attributes == "true/-|true/true")+ }+ @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.")]
diff --git a/specs/bugfixes/footnote-badge-occurrence-drift/report.md b/specs/bugfixes/footnote-badge-occurrence-drift/report.mdnew file mode 100644index 00000000..e64d18ca--- /dev/null+++ b/specs/bugfixes/footnote-badge-occurrence-drift/report.md@@ -0,0 +1,218 @@+# Bugfix Report: Hidden Comments Skew Repeated-Footnote Search Badge Occurrence++**Date:** 2026-09-07+**Status:** Fixed++## Description of the Issue++When a block contains a footnote reference (`[^id]`) hidden inside an HTML+comment, followed by two or more LIVE references to the same footnote,+navigating search to the second (or later) live occurrence highlighted the+wrong badge — the first live badge was marked again instead of the second.++**Reproduction steps:**+1. Open a document containing a paragraph such as+ `See <!-- [^1] --> First[^1] Second[^1]`, with `[^1]: alpha` defined and+ HTML comments hidden (the default).+2. Search for `alpha` (matches footnote 1's content).+3. Navigate to the second counted footnote match.++**Expected:** the section's second badge (`Second[^1]`) is marked current.+**Actual:** the section's first badge (`First[^1]`) is marked current again.++**Impact:** Search navigation misidentifies which footnote badge is current+whenever a hidden (or otherwise comment-gated) `[^id]` occurrence precedes+live occurrences of the same footnote in a block. Cosmetic/navigation-only —+no data loss — but confusing when using search to jump between repeated+footnote references.++## Investigation Summary++T-1968 (PR #416) had already documented the shape of this exact drift while+fixing a neighbouring one (escaped occurrences): `SearchStateFeeder+.referencedFootnoteIdentifiers` (and therefore `footnoteMatchCounts`) scans+`MarkdownBlock.footnoteScanComponents(...).scanSource` — the inline source+with HTML comments excised (or, on the toggle, joined with surfaced comment+bodies) — while `badgeEligibility` scanned the RAW, unmodified inline source.+For a block with no comments the two scans see identical text and stay+aligned; for a block with a hidden comment, `referencedFootnoteIdentifiers`+produces one array entry per LIVE occurrence while `badgeEligibility`+produced one flag per RAW occurrence (comment included) — a longer array,+misaligned from the first entry after the hidden occurrence onward.++`currentMatch` (`SearchStateFeeder.swift`) walks `footnoteMatches` (built from+`referencedFootnoteIdentifiers`) to find which reference occurrence owns an+overflow search-match position, and for each candidate counts prior+badge-eligible entries by indexing straight into the `badgeEligibility` flags+array with the SAME index. With the two arrays different lengths/shapes, that+index landed on the wrong flag.++Reproduced with a Swift Testing regression+(`WebSearchBridgeTests.hiddenCommentReferenceKeepsFeederArraysAligned` and+`hiddenCommentOrdinalsWalkTheLiveOccurrences`) before the fix: navigating to+the second live occurrence resolved to `.badge(id: "1", occurrence: 0)`+instead of the expected `.badge(id: "1", occurrence: 1)`.++- **Symptoms examined:** `SearchStateFeeder.currentMatch`'s occurrence math,+ `badgeEligibility`'s and `referencedFootnoteIdentifiers`'s scan sources.+- **Code inspected:** `prism/Services/SearchStateFeeder.swift`,+ `prism/Models/MarkdownBlock.swift` (`footnoteScanComponents`,+ `combinedSearchableText`), `prism/Services/WebRendering/InlineHTMLRenderer.swift`+ (`visitInlineHTML`, to confirm a `[^id]` inside any HTML comment — hidden or+ CSS-visible — never renders as a badge, since the comment's inner text is+ always escaped verbatim rather than re-parsed).+- **Hypotheses tested:** whether the fix should stay "translate indices at the+ point of use" (rejected — the same class of bug already survived one such+ fix, T-1968, because a second, independent divergence source existed);+ whether `badgeEligibility` should scan raw source and rebase+ `referencedFootnoteIdentifiers`'s output instead (rejected — reordering risk+ for the toggle-on "surfaced comment body" case, see Alternatives below).++## Discovered Root Cause++`SearchStateFeeder.badgeEligibility` scanned the raw, comment-including inline+source, independently from `SearchStateFeeder.referencedFootnoteIdentifiers`,+which scans the comment-gated `scanSource`. The two arrays this produces+(`footnoteMatches`, `badgeEligibility`'s flags) are consumed as if+index-aligned by `currentMatch`, but nothing enforced that alignment — a block+containing a comment-hidden footnote reference broke it silently.++**Defect type:** Coordinate-space mismatch between two independently-derived+arrays consumed as if index-aligned.++**Why it occurred:** T-1968 fixed the ESCAPING half of this same class of bug+(both functions now apply the identical `FootnoteData.isEscaped` predicate)+but explicitly left the COMMENT-GATING half as a documented, un-fixed residual+in `badgeEligibility`'s doc comment — this ticket is that residual.++**Contributing factors:** `badgeEligibility` needs RAW source offsets to query+`EmittedDocument.badgeSourceStarts` (deliberately keyed in raw-source+coordinates, since that's what the renderer actually parsed), which is+presumably why it scanned raw source directly instead of the transformed+`scanSource` — but scanning a different TEXT than `referencedFootnoteIdentifiers`+means iterating a different SET of occurrences whenever a comment is present.++## Resolution for the Issue++**Changes made:**+- `prism/Services/SearchStateFeeder.swift` — `badgeEligibility` now iterates+ the same `scanSource` (`MarkdownBlock.footnoteScanComponents`) that+ `referencedFootnoteIdentifiers` scans, so both arrays are built from+ identical text with identical filters (comment gating, escape, resolvability)+ applied in the same order — misalignment is no longer possible by+ construction. For a match found in the BASE (non-comment) portion of+ `scanSource`, a new helper `rawOffset(forBaseOffset:matches:)` maps its+ offset back to the ORIGINAL raw source (needed because `badgeStarts` is+ keyed in raw coordinates). For a match found in a SURFACED comment body+ (toggle on, accepted comment shape), the flag is unconditionally `false` —+ `InlineHTMLRenderer.visitInlineHTML` always escapes an inline HTML comment's+ text verbatim rather than re-parsing it, so a footnote reference inside any+ comment (hidden or visible) never renders a badge, and no offset lookup is+ needed to know that.++**Approach rationale:** The task brief for this fix asked for ONE coordinate+space rather than translating indices at the point of use. `scanSource` — the+comment-gated text `referencedFootnoteIdentifiers` already treats as+authoritative for "which occurrences count" — is that single coordinate space:+`badgeEligibility` now derives its per-occurrence set from the exact same scan,+so the two arrays can never drift in LENGTH or SHAPE again, only rebasing a+matched occurrence's OFFSET when it actually needs to query raw-coordinate+badge starts.++**Alternatives considered:**+- Make BOTH functions scan the raw source, with `referencedFootnoteIdentifiers`+ skipping raw occurrences whose containing comment is hidden/rejected —+ rejected because it would change the RELATIVE ORDER of occurrences for the+ toggle-on case where a comment sits mid-paragraph (today `scanSource` orders+ base text first, then surfaced comment bodies), risking a regression in+ `currentMatch`'s overflow walk for blocks mixing base-text and+ surfaced-comment-body footnote references — a real but narrow scenario with+ no existing test coverage either way.+- Translate `badgeEligibility`'s existing raw-source-produced index into+ `referencedFootnoteIdentifiers`'s coordinate space at the `currentMatch` call+ site — rejected per the task brief: this is exactly the "point of use+ translation" pattern that already failed once (T-1968 fixed escaping this+ way, but comment-gating was left as a residual because a second translation+ point is a second place to get it wrong).++## Regression Test++**Test file:** `prismTests/WebRendering/WebSearchBridgeTests.swift`+**Test names:**+- `hiddenCommentReferenceKeepsFeederArraysAligned`+- `hiddenCommentOrdinalsWalkTheLiveOccurrences`+- `hiddenCommentReferenceRendersNoBadge` (pins the premise: a comment's+ `[^id]` never becomes a DOM badge)+- `liveCurrentBadgeAfterHiddenCommentReference` (live-DOM regression over a+ real rendered page, per the ticket's request for a live-DOM test alongside+ the feeder-level one)++**What it verifies:** With a leading hidden-comment `[^1]` occurrence followed+by two live `[^1]` occurrences, `matchedFootnoteIds` has exactly two entries+(the hidden one contributes nothing) and navigating to the second live+occurrence resolves to `.badge(id: "1", occurrence: 1)`, not `occurrence: 0`.+The live test additionally confirms the rendered DOM's second badge, not the+first, is marked `data-prism-search-current`.++**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 -enableCodeCoverage NO \+ -only-testing:prismTests/WebSearchBridgeTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/SearchStateFeeder.swift` | `badgeEligibility` rewritten to scan the same `scanSource` as `referencedFootnoteIdentifiers`; new `rawOffset(forBaseOffset:matches:)` helper rebases a base-portion match to raw-source coordinates for the `badgeStarts` lookup; doc comments updated to record the fix and the T-2025 reference. |+| `prismTests/WebRendering/WebSearchBridgeTests.swift` | Added the T-2025 regression tests (feeder-level + live-DOM). |+| `CHANGELOG.md` | Added `[Unreleased] / Fixed` entry. |++## Verification++**Automated:**+- [x] Regression test passes (`WebSearchBridgeTests`, 33/33)+- [x] Related suites pass: `MarkdownBlockSearchContextTests`,+ `FootnoteBadgeSubstitutionTests`, `FootnoteBadgeGrowthTests`,+ `WebSearchParityTests`, `InlineSourceMapScanGrowthTests` (50/50)+- [ ] Full test suite — NOT run; out of scope per validation constraints for+ this session (see note below)+- [x] Linters/validators pass (`make lint`: 0 violations, 580 files)+- [x] `make build-macos` and `make build-ios` both succeed+- [x] `make verify-test-isolation` passes (static + unit guard suite)++**Manual verification:** Traced the exact reproduction source+(`See <!-- [^1] --> First[^1] Second[^1]`) through `rawOffset` and+`badgeEligibility` by hand to confirm occurrence 1 is produced for the second+live reference (see PR description / commit for the worked trace).++**Note on scope:** the full `make test`/`make test-quick` suite was not run in+this session; only the targeted suites above (SearchStateFeeder/footnote/+search-related, ~83 tests total) were exercised, plus both platform builds and+lint. This was a deliberate scope decision for this session, not a machine+failure — the targeted runs above completed cleanly, including the+`.liveWebKit`-gated tests.++## Prevention++**Recommendations to avoid similar bugs:**+- When two arrays are consumed as index-aligned, derive them from ONE shared+ scan/filter rather than two independently-filtered scans over different+ (or even textually-identical-looking) inputs — this is the second time this+ exact class of bug has appeared between these two functions (T-1968, then+ T-2025).+- When a function's doc comment already says "the alignment is not+ unconditional, and the gap is pre-existing" (as `badgeEligibility`'s did),+ treat that as an open bug report, not a permanent caveat.++## Related++- T-1968 (PR #416): fixed the escaping half of this same class of drift, and+ is the origin of the doc comment that flagged this exact residual.+- T-1853: introduced badge-occurrence addressing in `currentMatch`.+- T-1713: introduced the comment-gated `scanSource` /+ `referencedFootnoteIdentifiers`.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8b947fb7..f9339d4a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A footnote reference hidden inside an HTML comment no longer skews which badge search marks as current when the same footnote is referenced again visibly afterward (T-2025). Two feeder arrays are consumed as index-aligned — one counting which occurrences of a footnote actually appear in the block's comment-gated searchable text, one flagging which of those occurrences render as a DOM badge — but the second was built by independently scanning the raw, comment-including source, so a hidden occurrence added an extra flag the first array had no matching entry for; every flag after it then answered for the wrong occurrence. Navigating to the second live reference to a footnote that also had a hidden occurrence earlier in the same block marked the first badge again instead of the second. Both arrays now derive from the same comment-gated scan, so they cannot drift out of length or order relative to each other; a comment-included occurrence still needs its offset rebased to the raw source to look up whether the renderer actually placed a badge there, but a reference hidden inside a comment — or one rendered as a comment's own visible-but-escaped text — is now known to carry no badge without a lookup at all, since an HTML comment's contents are always rendered as literal escaped text rather than re-parsed for footnote syntax. - Tapping a note in the compact Notes panel or the regular Notes sidebar now scrolls to the block that note is actually anchored to, instead of always the first occurrence of identical content elsewhere in the document (T-1929). Both note UIs navigated by passing the note's bare content-hash block id, which `BlockDOMID` resolves to its first (or first-visible) occurrence by design — the note's own stored heading path, already recorded for note storage/display disambiguation (T-209), was never consulted. Navigation now resolves the note's specific occurrence against that heading path and builds the same verified composite target TOC/search/scroll-restore already use, falling back to the previous first-occurrence behaviour only when a note carries no heading path (legacy notes) or its heading path no longer matches any occurrence (the block was relocated). A table-row or list-item note anchor resolves to its parent block's correct occurrence, since rows and items are not independently scrollable. - An open document now follows its file when you rename or move it in Finder, the Files app, or another file provider (T-1881). The app watches the open file through a file presenter, and that presenter never implemented the half of its contract that deals with the file moving: it kept pointing at where the file used to be, and so did the document. Reload then failed on a file that is perfectly readable, the title kept the old name, images beside the document were looked for in the old folder, and the notes were left filed under a path nothing would ever look up again. The presenter now retargets itself the moment it is told the file moved, and the document follows it — which is what carries the title, the Save destination, Reload, the remembered reading position and the page's image resolution across the move, since all of them are derived from the one property. The notes move with the document, and the record left at the old path is retired rather than stranded there. Moving them is its own operation rather than a reuse of the one Save As runs, because three things a Save As can take for granted are not true of a rename, and each of them lost notes: a renamed file's notes may not be loaded yet (a rename arriving before you have opened the notes pane, or before the document has finished loading, used to be reported as migrated while nothing moved); an old and a new name can be the same file, so renaming `readme.md` to `README.md` used to write the notes and then delete the file it had just written, taking every note with it; and the document is already at its new name by the time the app is told, so a note written in the instant the rename lands used to clear every note already on the document. Trashing an open document is no longer mistaken for a rename either — it arrives as one, measurably, so the notes would have been rewritten under a path inside the Trash and the ones at the real path deleted; the document now stays where it was, which is where Put Back returns it and where its notes are waiting. Two things deliberately do not move: the Recent Files entry still names the path you opened from (that is T-1842 / T-2172 / T-2173), and the security-scoped access the session holds is left alone — it was granted for the file itself and survives the rename, whereas releasing it is the one way to actually lose access to the file. Two files moved in quick succession — a rename followed by a drag into another folder, or an iCloud reorganisation — are followed all the way, with the notes taken from where they actually are rather than from the intermediate location the document only passed through; before this they were left behind at the original path while everything reported success. And if the notes cannot be written to their new location, you are now told so, with the same alert Save As raises, instead of the notes quietly ceasing to be the document's. - `HTMLImageSourceRewriter` no longer re-emits a mediated `src`/`srcset` value with an embedded quote character left unescaped (T-1942). The rewriter always re-emits these attributes double-quoted, but a value could still contain a raw `"`: the `rewrite` closure's `data:` passthrough hands unencoded `data:` URIs back unchanged, and `rewriteSrcset` re-joins a candidate's descriptor half — never passed through `rewrite` — verbatim. Either one could terminate the re-emitted attribute early, splicing the remainder into attribute position (e.g. `srcset='a.png 1x" onerror=… z='`). The value is now escaped for its double-quoted context before being written, so any quote characters it carries round-trip intact instead of breaking out. The escape leaves a character reference the value already carries alone (the `data:` passthrough re-emits the attribute's text as written, references undecoded, and the browser decodes it once), so an SVG `data:` URI whose author correctly wrote `&amp;` for a literal ampersand still renders that ampersand rather than the reference. This is defence in depth rather than a live exploit: the subsequent `HTMLSanitizer` (SwiftSoup) pass is the actual security boundary and already reduced the broken-out remainder to non-allowlisted junk.
This PR closes drift between the two feeder arrays, soundly. It does not close drift between the feeder's idea of which text is commented out and what cmark actually parsed — which produces the same user-visible symptom by a different route. Two independent investigations landed on this same root cause, which is why I am confident in it rather than merely suspicious.
The root cause: HTMLCommentStripping.matches scans for a literal <!-- and takes the first --> anywhere after it, blind to context. cmark applies the grammar at swift-cmark/src/scanners.re:57 — "--" ([^\x00-]+ | "-" [^\x00-] | "--" [^\x00>])* "-->" — and only at a < that reaches handle_pointy_brace, i.e. not inside a code span or a link destination. Two families of disagreement follow:
Use `<!--` to open[^1] and `-->` to close. See[^1] again. — the delimiters are code spans, so cmark renders the first [^1] as text and badges it. The app's scanner excises the whole span, so the feeder sees only the second reference, emits occurrence: 0, and prism-search.js marks badges[0] — the pseudo-comment's badge. Wrong badge, on the default comments-off path. (Confirmed by a probe compiled against the project's own swift-markdown build.)-, e.g. <!-- a [^1] --->, is not derivable by that rule, so cmark refuses it while the app's scanner accepts it. Same outcome. (Static reading of the vendored grammar; not executed.)With showHTMLComments ON the span is instead filed as a surfaced body and this PR's new else branch flags it false while it genuinely badges. That is not a regression: pre-fix, the same input was wrong too — via the reordering between raw order and scanSource order — and produced the same wrong badge. The arrays also stay the same length either way, so the T-2025 alignment class really is closed; only the flag value is wrong, and currentMatch never reads the winner's own flag.
So: pre-existing, out of scope for #422, worth its own ticket. Nothing in the repo pins the two comment grammars against each other — the <!---> / <!-----> shapes are tested in HTMLCommentStrippingTests and RawHTMLImageScanGrowthTests, but only ever against the retired regex the scan replaced, never against cmark. The cheapest real fix is to stop hard-coding false and rebase surfaced-body offsets into raw coordinates too: a genuine comment's occurrence is not in badgeStarts, so it still yields false naturally, and the pseudo-comment shapes resolve correctly instead of by assumption.
prism-search.js's resolveCurrentBadge clamps: if (occurrence >= badges.length) { occurrence = badges.length - 1; }. That clamp is intentional (T-1853, so an ineligible winner addresses the next same-id badge rather than resetting to the first), but it means an off-by-one at the feeder can land on the same DOM element as a correct index whenever the error pushes past the end. liveCurrentBadgeAfterHiddenCommentReference is still worth having as a wiring check; the regression weight sits on the two feeder-level assertions. Worth keeping in mind if this area is ever tested only through the DOM.
currentMatch counts eligibility over (0..<matchIndex) only, so when the winning occurrence is itself ineligible the payload still names a badge — some other one. Pre-existing and deliberate (documented at currentMatch:441-452 and in the JS). This PR newly routes surfaced comment bodies into that class, so with comments on, a search ordinal belonging to a comment-embedded reference addresses the section's last badge. Harmless, and strictly better than the pre-fix behaviour on the same input, but it is the behaviour the untested toggle-ON branch produces.