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
Open
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofKBEditableView.body, taking the whole app down.Two
#rules disagreed:#lineKBMarkdownLexer.heading— 1–6 hashes followed by a space#prefixSo
#647 rests the whole migration…was rejected as a heading, fell into the paragraph branch, and the loop — starting atj = i— broke on its own first line.jnever advanced, andmake(.paragraph, i, j - 1)formed the empty rangei...(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 tomakewas not.Fix: the paragraph scan starts at
i + 1. Every other block kind has already been ruled out by then, so lineibelongs to the paragraph by construction and the loop only has to find where it ends. That establishesj > irather than clamping the symptom, and makes themaxunnecessary.This was firing in the real vault
An instrumented parser over
~/Scoutfound 57 files that crash on open. The triggers are Scout's own writing style — paragraphs opening with a bare ref or mnemonic:Scout was generating notes that crash Scout's own KB viewer.
scout-mistake-audit.mdis a file you open routinely.Interaction with #77
#77 landed a
ScoutMarkerbreak 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 linei, 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
KBTagrecognizes both forms and renders them identically (the brackets are presentation, not meaning):[#SLBETA]— what the plugin writes on action items#SLBETA— what dominates prose in research notesA tag is 2–8
[A-Z0-9]with at least one letter. That requirement is load-bearing: it keeps tags and GitHub refs disjoint, soGitHubRefLinkifierstill owns#123and the two rewriters compose over the same string without fighting. Same ruleActionItemsWriteruses 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 throughOpenURLActionand the run can be styled by its link attribute. Outside the KB (no handler) a chip renders but stays inert rather than handing ascout-tag://URL toNSWorkspace.Clicking one drives the existing left-pane search — query visible, familiar results list, clearing returns to the tree.
searchContentnow matches a tag query by tag rather than substring, so#KAIRELno longer drags in every#KAIRELXnote.Reviewer notes
The chip background is square, not rounded. SwiftUI's
Texthas 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.linkifyruns on everyInlineMarkdownTextcache 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:)wentprivate→ internal so tests can assert on the rendered runs. Pure function, no other caller.Verification
#-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'srawround-trips against the lines it claims, which is what in-place editing splices against.GitHubRefLinkifier, chip attributes on the renderedAttributedStringruns, 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