Skip to content

fix(kb): stop the KB viewer crashing on '#' lines that aren't headings + render [#TAG] as clickable chips - #92

Open
jordanrburger wants to merge 2 commits into
mainfrom
claude/scout-crash-kbdocsegment-095c86
Open

fix(kb): stop the KB viewer crashing on '#' lines that aren't headings + render [#TAG] as clickable chips#92
jordanrburger wants to merge 2 commits into
mainfrom
claude/scout-crash-kbdocsegment-095c86

Conversation

@jordanrburger

Copy link
Copy Markdown
Collaborator

Fixes an app-killing crash in the KB viewer, then makes the vault's [#TAG] mnemonics first-class.

1. The crash

KBDocSegment.segments(from:) trapped (EXC_BREAKPOINT / SIGTRAP) straight out of KBEditableView.body, taking the whole app down.

Two # rules disagreed:

Where Rule for a # line
heading branch KBMarkdownLexer.heading — 1–6 hashes followed by a space
paragraph loop breaks on any # prefix

So #647 rests the whole migration… was rejected as a heading, fell into the paragraph branch, and the loop — starting at j = i — broke on its own first line. j never advanced, and make(.paragraph, i, j - 1) formed the empty range i...(i-1): "Range requires lowerBound <= upperBound".

The max(j, i + 1) on that same line is the tell — the loop index was already guarded against the no-advance case, but the range handed to make was not.

Fix: the paragraph scan starts at i + 1. Every other block kind has already been ruled out by then, so line i belongs to the paragraph by construction and the loop only has to find where it ends. That establishes j > i rather than clamping the symptom, and makes the max unnecessary.

This was firing in the real vault

An instrumented parser over ~/Scout found 57 files that crash on open. The triggers are Scout's own writing style — paragraphs opening with a bare ref or mnemonic:

scout-mistake-audit.md:1099   #1070 would then be clean; it came back `CONFLICTING`…
2026-07-29-redos-sqlsplit-gil-prior-art.md:100   #647's in-loop cooperative deadline…
2026-08-05-scheduled-run-auth-failure…md:14      #Q2REVIEW resurrected daily until…

Scout was generating notes that crash Scout's own KB viewer. scout-mistake-audit.md is a file you open routinely.

Interaction with #77

#77 landed a ScoutMarker break in this same paragraph loop while this branch was open. It tests the untrimmed line (isMarkerLine(lines[j])) where its guard branch tests the trimmed one (isMarkerLine(t)) — the same predicate-mismatch shape that caused this bug. With the loop starting past line i, no mismatch of that kind can produce an empty range any more. Rebased onto it; both changes coexist and its tests pass.

2. Tag chips

43,306 occurrences of 903 distinct tags across 298 notes were rendering as undifferentiated prose, with no way to pivot from a tag to the notes sharing it.

New KBTag recognizes both forms and renders them identically (the brackets are presentation, not meaning):

  • bracketed [#SLBETA] — what the plugin writes on action items
  • bare #SLBETA — what dominates prose in research notes

A tag is 2–8 [A-Z0-9] with at least one letter. That requirement is load-bearing: it keeps tags and GitHub refs disjoint, so GitHubRefLinkifier still owns #123 and the two rewriters compose over the same string without fighting. Same rule ActionItemsWriter uses to reject [#555]. Tags inside inline code, wikilinks and markdown links are left alone.

Chips reuse the scout-tag:// link mechanism wikilinks already use, so clicks arrive through OpenURLAction and the run can be styled by its link attribute. Outside the KB (no handler) a chip renders but stays inert rather than handing a scout-tag:// URL to NSWorkspace.

Clicking one drives the existing left-pane search — query visible, familiar results list, clearing returns to the tree. searchContent now matches a tag query by tag rather than substring, so #KAIREL no longer drags in every #KAIRELX note.

Reviewer notes

The chip background is square, not rounded. SwiftUI's Text has no corner-radius attribute for an inline run. The alternatives cost more than they're worth: a wrapping layout breaks prose flow, and baking each chip into an image means it can't carry the link that makes it clickable. Hair spaces around the label supply the inset. If the square corners read badly in practice, that's the thing to push back on.

Perf. KBTag.linkify runs on every InlineMarkdownText cache miss — a scroll-visible path this repo has already had to optimize (#88). It early-outs on a single byte scan when the string has no #: 0.24 µs vs 18 µs, and most rendered strings have none.

Visibility change. InlineMarkdownText.attributedString(for:) went private → internal so tests can assert on the rendered runs. Pure function, no other caller.

Verification

  • 472 tests / 86 suites pass (includes feat(kb): Comment for Scout — marker vocabulary, parser chip, block affordance (KB phase) #77's new tests).
  • The fixed parser walks all 13,483 vault files — 1,073,322 segments, no trap, zero range/raw mismatches. 57 of those files trapped before.
  • Segmentation is byte-identical to the previous parser on documents that didn't crash.
  • Crash coverage: 9 parameterized #-not-a-heading shapes, consecutive tag lines, paragraph boundaries, plus a range invariant over all 676 two-line documents built from 26 line shapes — asserting every segment's raw round-trips against the lines it claims, which is what in-place editing splices against.
  • Tag coverage: recognition, protected spans, prefix collisions, linkify composition with GitHubRefLinkifier, chip attributes on the rendered AttributedString runs, and tag-vs-substring search semantics.

Not verified: how it actually looks. Scout was running from another build and I didn't want to launch a competing instance, and I can't drive the macOS UI to navigate to a tagged note. Worth an eyeball on scout-mistake-audit.md — it has both a previously-crashing line and real tags.

🤖 Generated with Claude Code

jordanrburger and others added 2 commits August 23, 2026 20:34
`segments(from:)` crashed the whole app (EXC_BREAKPOINT, SIGTRAP) from
`KBEditableView.body` whenever a KB note contained a line opening with `#`
that `KBMarkdownLexer.heading` rejects — no space after the hashes, 7+
hashes, or a bare `#`.

Two `#` rules disagreed. The heading branch asks the lexer (1–6 hashes
*then a space*); the paragraph loop broke on any `#` prefix. So `#647 …`
fell past the heading branch into the paragraph branch, where the loop —
starting at `j = i` — broke on its own first line. `j` never advanced and
`make(.paragraph, i, j - 1)` formed the empty range `i...(i-1)`:
"Range requires lowerBound <= upperBound". The `max(j, i + 1)` on that
line guarded the loop index against the same no-advance case but not the
range.

The paragraph scan now starts at `i + 1`. Every other block kind has
already been ruled out by then, so line `i` belongs to the paragraph by
construction and the loop only has to find where it *ends*. That
establishes `j > i` rather than clamping the symptom, and makes the `max`
unnecessary.

This is not hypothetical: 57 files in a real vault hit it, including
`scout-mistake-audit.md`. The triggers are Scout's own writing style —
paragraphs opening with `#647`, `#1355`, `#SLBETA`, `#Q2REVIEW`. Scout was
generating notes that crash Scout's KB viewer.

It also neutralizes the same bug class for the `ScoutMarker` break added
in #77, which tests the untrimmed line where its guard branch tests the
trimmed one — with the loop starting past line `i`, no predicate mismatch
there can produce an empty range.

Verified: the fixed parser walks all 13,474 vault files (1,071,081
segments) with no trap, and segmentation is byte-identical to the previous
parser on documents that didn't crash.

Tests: 9 parameterized `#`-not-a-heading shapes, consecutive tag lines,
paragraph boundaries, plus a range invariant over all 676 two-line
documents built from 26 line shapes — asserting every segment's `raw`
round-trips against the lines it claims, which is what in-place editing
splices against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vault

The vault's semantic mnemonics rendered as undifferentiated prose — 43,306
occurrences of 903 distinct tags across 298 notes, invisible in the reading
column and with no way to pivot from one to the notes that share it.

New `KBTag` recognizes both forms and renders them identically, since the
brackets are presentation rather than meaning:

  - bracketed `[#SLBETA]`, the form the plugin writes on action items
  - bare `#SLBETA`, the form that dominates prose in research notes

A tag is 2–8 `[A-Z0-9]` containing at least one letter. That letter
requirement is load-bearing: it keeps tags and GitHub refs disjoint, so
`GitHubRefLinkifier` still owns `#123` and the two rewriters compose over
the same string without fighting. It's the rule `ActionItemsWriter`
already uses to reject `[#555]`. Tags inside inline code, wikilinks and
markdown links are left alone, mirroring `GitHubRefLinkifier`'s protected
ranges.

Chips reuse the `scout-tag://` link mechanism wikilinks already use, so
clicks arrive through `OpenURLAction` and the run can be styled by its
link attribute — accent ink on accent wash, in a smaller monospace face.
Outside the KB (no handler) a chip renders but stays inert rather than
handing a `scout-tag://` URL to NSWorkspace.

Clicking one drives the existing left-pane search, so the query is
visible, the results list is the familiar one, and clearing it returns to
the tree. `searchContent` now matches a tag query by tag rather than
substring — otherwise `#KAIREL` also reports every `#KAIRELX` note, and
tags are short enough that those collisions are common.

Known limitation: the chip background is square. SwiftUI's `Text` has no
corner-radius attribute for an inline run, and the alternatives cost more
than they're worth — a wrapping layout breaks prose flow, and baking each
chip into an image means it can't carry the link that makes it clickable.
Hair spaces around the label supply the inset.

`KBTag.linkify` runs on every `InlineMarkdownText` cache miss, which is a
scroll-visible path this repo has already had to optimize (#88), so it
early-outs on a single byte scan when the string has no `#` at all:
0.24 µs vs 18 µs, and most rendered strings have none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant