prism branch T-1840/bugfix-…-as-mermaid commits 4 + review fixes files 3 touched lines +574 / -33 targeted tests 115 / 115 pass lint 0 violations

Pre-push review: T-1840 clipboard prose rewritten as mermaid

Clipboard paste auto-wraps text that looks like raw mermaid source in a ```mermaid fence. Ordinary prose was tripping that check. Four commits over five review rounds narrow it to a three-tier heuristic. This review found — and fixed — a detection regression the five rounds missed: class, ER, and state diagrams whose member block spans lines silently stopped being detected.

At a glance

  • Verified no new false positives. A standalone replica of both main's and the branch's logic over a 40-case corpus: every prose shape the branch wraps, main wrapped too. The branch is a strict subset of main's wrapping, apart from the multi-line-span corner.
  • Fixed: a real detection regression. Fields-only classDiagram / erDiagram / stateDiagram bodies stopped being detected when matching went per-line. Restored with a member-block-opener pattern plus four regression tests.
  • Fixed: two false CHANGELOG claims. section was listed as applying to pie (it does not — only title does), and "detection of every real diagram shape is unchanged" was untrue for period-terminated gantt/journey/pie directive lines.
  • Added: coverage for four unpinned branches. The trimmed.last != "." clause, the Timeline and Pie Chart directive pairings, maxDeclarationTokenCount, and the %% comment skip could each be deleted with a green suite. Now pinned.
  • Consolidation question answered: follow-up, not this PR. declaredDiagramType(in:) duplicates only ~6 lines of MermaidTypeParser's line walk. Its token-count rule is a clipboard heuristic, not mermaid grammar, so it does not belong in the parser — and tightening parse is off the table (two user-visible callers, ~40 assertions).
  • No pre-existing test was modified. Contrary to the working note, the diff against origin/main is a pure append (+348 / −0) — the two lines rewritten in round 4 belonged to a test this branch itself added in round 3.
  • Not a performance regression. Measured: the per-line rewrite is 15–25% faster than main on the 10 MB worst case, because per-line matching caps ICU backtracking. Both still block the main thread for ~11 s at that size — pre-existing, worth a follow-up bound.

Verdict

Ready to push

Ready to push, after the fixes applied during this review. The shipping heuristic is sound and the change is, with one exception, a strict safety improvement: I replicated both origin/main's and the branch's logic in a standalone harness and ran a 40-case corpus through each — every prose input the branch wraps, main wrapped too, so no new false positive was introduced.

The exception was real and is now closed. Moving from whole-text to per-line matching silently un-detected classDiagram, erDiagram, and stateDiagram sources whose member block spans lines (class Animal{ / +int age / }) — the form used in mermaid's own documentation. \w\{[^}]+\} requires the brace pair to close on one line. The code comment that considered this exact corner reached the wrong conclusion, calling it "not valid diagram source"; for the brace shape it is the member block, not a label. No test covered it, which is why five rounds missed it. A member-block-opener pattern in the weak tier restores all three, verified against an 18-case corpus with zero new false positives.

Two CHANGELOG statements were also factually wrong and are corrected. Everything else is residual scope inherited from main, or follow-up work — recorded below, none of it blocking.

Review findings

14 raised · 7 fixed · 7 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism lets you paste text straight from the clipboard and read it as a document. As a convenience, if the pasted text looks like a mermaid diagram written without its code fence, Prism adds the fence for you so the diagram renders instead of showing as plain text.

The trouble was that the "looks like a diagram" test was too easy to satisfy. Several mermaid keywords are also everyday English words — graph, pie, journey, timeline, kanban, architecture, block. So a paragraph that merely started with one of those words passed the first check. It then only needed one more clue, and the clues were things ordinary writing contains: a numbered citation like results[3], a bracket-free aside like the tool(a favorite), or a sentence beginning with the word "section" or "title". Paste such a paragraph and Prism would wrap it as a diagram — and since it was not a diagram, you got a broken one.

Why it matters

You paste some prose and the app silently rewrites it into something unreadable. Nothing is lost, but the feature actively works against you.

How it was fixed

The clues are now sorted by how likely ordinary writing is to produce them. Arrows and similar operators (-->, ->>) are things prose essentially never writes, so they always count. Brackets, parentheses and the words "section"/"title" are things prose writes constantly, so they are ignored on any line that ends the way a sentence ends — with a full stop, question mark or exclamation mark.

One catch: real diagram titles genuinely do ask questions (title Are we on track?). So those keywords get a narrow pass — but only when the text actually opens by declaring a diagram, i.e. the keyword standing alone on its own line (gantt), not a paragraph that merely begins with it.

What this review added

The rewrite had an unnoticed side effect. Because the check now looks at one line at a time rather than the whole text at once, some diagrams whose shape spans several lines stopped being recognised — specifically class, ER and state diagrams that list their fields across lines. Those are now detected again, and the changelog's description was corrected where it was inaccurate.

Architecture

ClipboardService.validateContent runs wrapMermaidIfNeeded on every paste. That function is a conjunction of four gates: no existing code fence, MermaidTypeParser.parse recognises the first word, containsMermaidSyntax finds diagram syntax, and there are 2+ non-empty lines. Only the third gate changed.

The three tiers

containsMermaidSyntax went from one whole-text regex pass over a flat 22-pattern list to a per-line loop over three tiers:

  • Strong (16 patterns) — arrow operators, class/ER relationship operators, pie's "label": number. Matched on every line unconditionally, because a sequence-diagram message legitimately ends in sentence punctuation (Alice->>Bob: Are you there?).
  • Weak shape (9, now 10) — bracket/paren/brace node shapes, edge-label pipes, state markers, -x. Suppressed on any line ending in ., ? or !, because each has a prose double.
  • Weak directive (3) — section, title, dateFormat, each paired with the set of diagram types that actually use it. Same suppression, with one exemption.

The exemption, and why it needs two conditions

Round 2 gated everything on sentence punctuation, which broke genuine title Are we on track? lines. Round 3 exempted ?/! for directives — but that reopened the bug, since a paragraph starting "Timeline mapping is useful" also parses as Timeline. Round 4 added the second condition: declaredDiagramType(in:) requires the opening line to be a bare declaration (keyword plus at most one direction/modifier token), and the declared type must admit that specific directive. Both conditions are necessary; either alone regresses in one direction.

Trade-offs

A trailing full stop is never exempt, in any type. That is deliberate — a period-terminated title reads as prose everywhere — and it does narrow detection: gantt + title Project schedule. no longer wraps. The spec's stated preference ("false positives are worse than false negatives") supports the call; the CHANGELOG denying it was the problem, now fixed.

What this review changed

Per-line matching means negated-character-class patterns can no longer span a newline. The author reasoned about this and judged it safe. That holds for bracket, paren and quote labels; it does not hold for \w\{[^}]+\}, because a multi-line brace block is a class/ER/state member block, and a fields-only body carries no other syntax on any line. A member-block-opener pattern in the weak tier restores it.

The failure mode, precisely

The bug class is a conjunction whose weakest conjunct does no work. MermaidTypeParser.parse inspects only the first whitespace-delimited token of the first non-comment line, by design — it exists to label source already known to be a diagram, so permissiveness costs it nothing. Reused as an admission gate it admits any paragraph whose first word collides with a keyword, and ten of the 26 keywords are ordinary English. That reduces the whole heuristic to containsMermaidSyntax, which was a flat disjunction over 22 patterns applied to the whole text.

Why the punctuation discriminator is the right shape — and its limits

The insight is that mermaid syntax lines are terse and unterminated while prose sentences terminate. That is a cheap, language-free discriminator. But it is evaluated on the line's final character, which bounds what it can cover: it fires on terminated sentences only. Hard-wrapped prose, headings, bullet lists, table rows, and sentences closed by a quote or bracket ("stop[3]!") all slip through. I verified each still wraps. All were wrapping on main too, so none is a regression — but the doc comments and CHANGELOG present the gate as the prose/diagram discriminator, which overstates it. The residual class is narrowed, not closed.

The regression per-line matching introduced

Whole-text matching gave the negated character classes ([^\]], [^)], [^}], [^"]) implicit newline-spanning power. The doc comment retires that deliberately, arguing mermaid labels use <br/> and a raw newline inside a bracket label is invalid. Sound for labels — but \w\{[^}]+\} was not matching a label. It was matching the member block:

classDiagram
class Animal{
 +int age
 +String gender
}

Fields-only bodies carry no arrow, no paren, no quoted number — nothing on any single line. Same for erDiagram CUSTOMER{ … } and composite state First{ … }. A class diagram with methods survives incidentally (+deposit(amount) matches the paren shape), which is exactly the kind of partial survival that hides a regression from spot checks. Verified against both implementations: main wraps all three, the branch wrapped none.

The fix is #"\w[^\S\n]*\{[^\S\n]*$"# in the weak tier — an identifier followed by an unclosed brace at line end. It is in the weak tier for consistency, though the punctuation gate is vacuous for it (a line ending in { never ends in .?!). Prose ending in identifier{ is essentially nonexistent, and pasted pseudo-code is excluded because if (x) { has a non-word character before the brace.

Residual holes in the new mechanism

maxDeclarationTokenCount = 2 accepts flowchart LR, but also accepts two-word prose openers: Timeline overview / section 4 answers this? wraps, as does Journey mapping / title case matters, right?. The headline test for this exemption uses the three-token Timeline mapping is useful, which passes while its two-token sibling fails. Tightening would mean constraining the second token to a direction or known modifier rather than counting it — a design change, not a fix, so it is recorded as follow-up rather than applied here.

Performance

Measured, not estimated: on a 10 MB no-match paste the per-line version runs 11.4 s against main's 15.3 s — 15–25% faster, because per-line ranges cap ICU backtracking on the negated classes that previously scanned the whole buffer. So this is not a regression. It is, however, still an ~11 s synchronous main-thread stall at the 10 MB ceiling, plus three full components(separatedBy:) materialisations, two of which exist to read a single line. Bounding the scan is a ~3-line change worth roughly 490× on that path, and belongs in its own ticket.

Important changes — detailed

containsMermaidSyntax: flat disjunction becomes three punctuation-gated tiers

ClipboardService.swift

Why it matters. This is the whole fix. Everything else in the diff supports it. It converts one whole-text regex pass over 22 patterns into a per-line loop that classifies each pattern by how plausibly prose produces it — and that per-line move is also what caused the one regression found in this review.

What to look at. ClipboardService.swift:320-350 (containsMermaidSyntax), :53-76 (strong), :96-116 (weak shape), :132-139 (weak directive)

Takeaway. When a heuristic over-fires, the useful question is rarely "which pattern is wrong" but "which patterns are evidence and which are merely consistent". Splitting one flat disjunction into evidence tiers, each with its own admission gate, keeps every pattern's detection power while letting the ambiguous ones be conditioned on context. The alternative — deleting the ambiguous patterns — would have cost detection of pie, gantt, journey and timeline, which have no operator syntax to fall back on.
Rationale. The strong tier is ungated because arrows are unambiguous regardless of how the label ends — a sequence-diagram message routinely ends in sentence punctuation (Alice->>Bob: Are you there?), so gating it would have broken a mainstream diagram type. The weak tiers are gated because each pattern has a documented prose double.

declaredDiagramType(in:): the second condition that keeps the exemption honest

ClipboardService.swift

Why it matters. The narrowest and most easily misread part of the design. It exists because the ?/! exemption added in round 3 reopened the original bug, and it is the only thing separating a punctuated gantt title from a hard-wrapped paragraph that merely starts with a keyword.

What to look at. ClipboardService.swift:372-385, consulted at :336-339

Takeaway. A permissive parser reused as an admission gate is a recurring bug source. MermaidTypeParser.parse only looks at the first word — correct for labelling source already known to be a diagram, wrong for deciding whether something is one. Rather than tightening the shared parser (which two user-visible callers depend on staying permissive), the strict question gets its own local predicate. Naming the strict variant separately, instead of hardening the lenient one, is usually the safer refactor.
Rationale. Requiring a bare declaration — keyword plus at most one direction/modifier token — is what distinguishes `gantt` from `Timeline mapping is useful`. Pairing each directive with the types that admit it stops `flowchart` + `section Ship it!` claiming an exemption for a directive flowcharts do not have.

REGRESSION FOUND AND FIXED: multi-line member blocks stopped being detected

ClipboardService.swift

Why it matters. A genuine functional regression that survived five review rounds and 80 tests. Fields-only class, ER and state diagrams — the form in mermaid's own docs — silently stopped auto-wrapping, because per-line matching removed the newline-spanning power \w\{[^}]+\} depended on.

What to look at. ClipboardService.swift:96-118 (member-block-opener pattern), :309-322 (corrected doc comment); tests at ClipboardServiceTests.swift:795-830

Takeaway. When a refactor narrows a matcher's scope, audit what each pattern was actually matching, not what its name or comment says it matched. The comment here reasoned about the newline-spanning corner and dismissed it as label syntax — true for brackets and quotes, false for braces, where the multi-line form is the member block and the only syntax present. A class diagram with methods survives incidentally, so spot-checking one fixture would not have caught it.
Rationale. Verified by replicating both implementations and diffing outcomes over a corpus: main wraps all three shapes, the branch wrapped none. The restoring pattern was validated against 18 cases covering every existing test fixture with zero new false positives.

CHANGELOG: two factual claims corrected

CHANGELOG.md

Why it matters. The entry is the durable user-facing record of a fix with no spec update and no bugfix report, so its accuracy carries more weight than usual here.

What to look at. CHANGELOG.md:22

Takeaway. A changelog that describes the mechanism rather than the behaviour has to be re-verified against the code every time the mechanism changes across review rounds. Both errors here are round-4 drift: the entry described the design as of round 3.
Rationale. Verified empirically. `pie` + `section Ship it!` does not wrap — `section` maps to gantt/journey/timeline only, and the code's own doc comment says so correctly. And `gantt` + `title Project schedule.` no longer wraps, so "detection of every real diagram shape is unchanged" was false for exactly the four types the sentence named.

Test coverage: four load-bearing branches were deletable with a green suite

ClipboardServiceTests.swift

Why it matters. 80 tests, yet the single most load-bearing sub-clause of the fix — `trimmed.last != "."` — could be removed without failing anything, because every period-terminated directive test used a prose opener that would return false regardless.

What to look at. ClipboardServiceTests.swift:832-858 (new pinning tests)

Takeaway. A test that exercises a code path is not the same as a test that pins it. These cases all reached the right answer through a different branch than the one under test, so the suite looked thorough while leaving the clause free. Checking coverage by deleting the branch and re-running is a fast way to find this.
Rationale. Also pinned: the Timeline and Pie Chart directive pairings (never consulted, so a display-name rename would silently disable the exemption), maxDeclarationTokenCount, and the %% comment skip.

Key decisions

Strong operator patterns are ungated; weak shape and weak directive are both gated on <code>.</code>/<code>?</code>/<code>!</code>.

Arrows, class/ER relationship operators and pie's "label": number match on any line. Gating them would break sequence diagrams, whose messages routinely end in sentence punctuation. The ambiguous shapes and directives are suppressed on terminated lines because each has a common prose double.

The <code>?</code>/<code>!</code> exemption requires BOTH a real declaration and a type that admits the directive.

Round 3 exempted ?/! for directives alone, which reopened the bug — a paragraph opening "Timeline mapping is useful" parses as Timeline, a type that genuinely uses section. Round 4 added declaredDiagramType(in:). Both conditions are load-bearing: the declaration check rejects prose openers, the type pairing rejects flowchart + section.

A trailing full stop is never exempt, in any diagram type.

Deliberate, per the doc comment: a period-terminated title reads as prose everywhere. It does narrow detection — gantt + title Project schedule. no longer wraps — which the spec's "false positives are worse than false negatives" supports. The CHANGELOG denying it has been corrected.

The brace shape gets an explicit member-block-opener pattern rather than a whole-text pass.

Applied during this review. Restoring a whole-text pass for the brace pattern would reintroduce unbounded backtracking on large pastes and split the matching model in two. A line-scoped \w[^\S\n]*\{[^\S\n]*$ keeps one model and one cost profile. Placed in the weak tier for consistency, though its punctuation gate is vacuous — a line ending in { never ends in .?!.

<code>declaredDiagramType(in:)</code> stays local to ClipboardService; consolidation is a follow-up.

Answering the review question directly. Only ~6 lines duplicate MermaidTypeParser's walk (split, trim, skip empty, skip %%); keyword extraction and lookup already delegate. The genuinely new part — maxDeclarationTokenCount — is a clipboard false-positive heuristic, not mermaid grammar (pie title Bugs per Module and gitGraph TB: are legal 3+ token openers), so a MermaidTypeParser.parseDeclaration would assert a rule the parser has no authority over. Measured blast radius on tightening parse: two user-visible production callers (BlockHTMLEmitter.swift:515, WebDocumentMessageRouter.swift:141) plus ~40 assertions across six test files, all of which depend on it staying permissive. Consolidating is therefore not right for this PR.

The stringly-typed display-name coupling is left standing, and recorded as follow-up.

weakMermaidDirectives keys on MermaidTypeParser's human-readable return values. A rename would silently disable the exemption with no compile error. The compiler-enforced fix is a MermaidDiagramType enum with a displayName, which would also clean up iconName(for:) — but it touches files outside this bugfix. Mitigated here by adding tests that exercise every directive pairing, so a rename now fails the suite.

Review findings

SeverityAreaFindingResolution
majorClipboardService.swift — per-line matchingMulti-line member blocks (classDiagram `class Animal{ … }`, erDiagram `CUSTOMER{ … }`, stateDiagram `state First{ … }`) stopped being detected. `\w\{[^}]+\}` requires the brace pair to close on one line, and a fields-only body carries no other syntax. Verified against both implementations: main wraps all three, branch wrapped none. Not covered by any test.Added `#"\w[^\S\n]*\{[^\S\n]*$"#` (identifier + unclosed brace at line end) to the weak shape tier, validated against an 18-case corpus with zero new false positives. Added four regression tests plus a negative test for pasted pseudo-code (`if (x) {`).
majorClipboardService.swift — doc commentThe comment on `containsMermaidSyntax` reasoned about the newline-spanning corner and dismissed it as out of scope: "a raw newline inside a plain bracket label is not valid diagram source, and any real diagram has other single-line syntax". True for bracket/paren/quote labels; false for the brace shape, where the multi-line form IS the member block and there is no other syntax. This wrong conclusion is why the regression shipped.Rewrote the comment to separate the two cases and state explicitly that the brace shape is not such a corner, with a pointer to the pattern that keeps it detected.
majorCHANGELOG.md:22States the exemption covers "`title` and `section` in gantt, journey, timeline, and pie". `section` maps to gantt/journey/timeline only — pie is `title`-only. Verified: `pie` + `section Ship it!` does not wrap. The code's own doc comment gets this right; the CHANGELOG dropped the qualifier.Corrected to "`title` in gantt, journey, timeline, and pie; `section` in the first three of those; `dateFormat` in gantt alone".
majorCHANGELOG.md:22Claims "Detection of every real diagram shape is unchanged, including the types with no arrow operator to fall back on (pie, gantt, journey, timeline)". False for exactly those types: `gantt`+`title Project schedule.`, `gantt`+`section Phase one.`, `journey`+`title My day.`, `pie`+`title Distribution.` all wrapped on main and no longer do. The narrowing is deliberate (`trimmed.last != "."`) but was being denied rather than documented.Replaced with an explicit statement of the trade-off, and extended the sentence to cover the multi-line member blocks now restored.
majorClipboardServiceTests.swift — coverageFour load-bearing branches were unpinned: (1) the `trimmed.last != "."` clause — deleting it left every test green, because both period-terminated directive tests use prose openers that return false regardless; (2) the Timeline and Pie Chart directive pairings are never consulted, so renaming either display name would silently disable the exemption; (3) `maxDeclarationTokenCount` is only exercised at 3 tokens, never at 1 or 2; (4) the `%%` comment skip in `declaredDiagramType`.Added three tests pinning all four: every directive pairing including the pie/section negative, period-terminated directives with a valid declaration, and a two-token declaration plus a leading `%%` comment.
majorClipboardService.swift:35 — maxDeclarationTokenCountA two-word prose opener satisfies the declaration test and claims the exemption: `Timeline overview` / `section 4 answers this?` wraps, as does `Journey mapping` / `title case matters, right?`. Two-word headings are a common shape for pasted prose. The headline test for this exemption uses the three-token `Timeline mapping is useful`, which passes while its two-token sibling fails. Not a regression (main wrapped both), but the new mechanism's own goal is incompletely met.Not fixed — closing it means constraining the second token to a direction or known modifier rather than counting tokens, which is a design change, not a fix, and this heuristic has already churned across five rounds. Recorded for follow-up.
minorClipboardService.swift:53-76 — strong tier framingThe strong tier is documented as "operator shapes that do not occur in prose", but two members do. `\w+[^\S\n]*---[^\S\n]*\w+` matches the ASCII em-dash convention (`settled---or so we thought`, and the spaced form too), and `o--` matches any word ending in `o` before a double hyphen (`ratio--surprisingly--held`). Both bypass the punctuation gate entirely, so period-terminated prose still wraps. Pre-existing on main, but the tiering decision is new and it is precisely what declares these unambiguous.Not fixed — tightening these (requiring whitespace or boundaries around `---`, anchoring `o--` with a lookbehind) changes strong-tier matching, which is the highest-risk area to touch after five rounds. Recorded for follow-up alongside the `-x` item.
minorClipboardService.swift:98 — the `-x` patternThe doc comment lists "a hyphenated word containing `-x` (`non-xml`)" among the prose doubles the gate handles. It does not: the gate only inspects the final character, and a hyphenated word almost never ends a sentence. `Graph databases are useful` / `for non-xml payloads today` wraps, as does a line containing `tar -xzf`. Pre-existing, but the comment claims a mitigation that does not exist.Not fixed. The clean fix reuses the machinery this branch added — gate `-x`/`--x` on `declaredDiagramType(in:) == "Sequence Diagram"`, exactly as directives are gated — but that extends the type-correlation mechanism to a new tier and deserves its own review. Recorded for follow-up.
minorClipboardService.swift — gate coverage framingThe punctuation gate is presented in doc comments and CHANGELOG as the discriminator between prose and diagram lines, but it covers terminated sentences only. Verified still wrapping: hard-wrapped prose (`Graph theory has broad applications[3] in` / `computer science`), sentences closed by a quote (`Bob asked "is this results[3]?"`), ellipsis endings, bulleted lists, and markdown tables under a keyword-led heading. All pre-existing; the residual class is narrowed, not closed.Not fixed (all pre-existing, none a regression). Recorded here and in the expert explanation so the next reader is not misled by the confident framing.
minorspecs/bugfixes/ + specs/clipboard-mermaid-detection/`specs/bugfixes/clipboard-prose-rewritten-as-mermaid/` exists on disk but is empty and untracked — 103 of 105 sibling directories carry a `report.md`. Separately, `specs/clipboard-mermaid-detection/smolspec.md` still documents a single flat pattern list with no tiering, punctuation gate, or declaration correlation, and smolspec.md:67 / tasks.md:12 say "keyword + multi-line OR syntax" where smolspec.md:36 and the code say AND.Not fixed — writing the bugfix report and updating the spec is author work requiring the decision rationale, not a review edit. Flagged as the highest-value follow-up: with the spec stale and no report, the only durable record of why the heuristic tiers this way is the doc comments. All 8 rows of the spec's Test Cases table still pass.
minorClipboardService.swift — stringly-typed coupling`weakMermaidDirectives` keys on `MermaidTypeParser`'s display names ("Gantt Chart", "User Journey", "Timeline", "Pie Chart"). A rename compiles clean and silently disables the exemption for that type. This is the third production site keying on those raw strings (`iconName(for:)` and the `"Diagram"` sentinel are the others).Partially mitigated: the new tests exercise every directive pairing, so a rename now fails the suite rather than passing silently. The compiler-enforced fix — a `MermaidDiagramType` enum with a `displayName` — touches files outside this bugfix and is recorded as follow-up.
minorClipboardService.swift — main-thread costMeasured, not estimated: 10 MB no-match paste takes 11.4 s on the branch versus 15.3 s on main, so the rewrite is 15-25% FASTER (per-line ranges cap ICU backtracking on the negated classes). But it is still an ~11 s synchronous main-thread stall at the 10 MB ceiling, and `wrapMermaidIfNeeded` makes three full `components(separatedBy:)` passes, two of which exist to read one line.Not fixed — not a regression, and the effective fix (bounding the scanned prefix and line count, worth ~490x on that path) is a behaviour-affecting optimisation that belongs in its own ticket rather than a bugfix branch already five rounds deep.
nitClipboardService.swift:134-138, :99, :336`(?m)` and `^\s*` in the three directive patterns are vestigial now that matching runs on an already-trimmed single line — and `(?m)` actively signals the whole-text contract this change removed. `#"--x"#` is a strict superstring of `#"-x"#` and can never be the sole matcher (same for `-->>` vs `-->`). `declaredType.map(directive.diagramTypes.contains) == true` compares a `Bool?` to `Bool` via a curried `Set.contains`, and is loop-invariant.Not fixed — all cosmetic, all zero-behaviour-change, and touching the core matching arrays for cosmetics after five rounds trades real risk for no user-visible gain. Recorded so a future cleanup pass has the list.
nitWorking note accuracyThe task note stated that "one pre-existing test was deliberately adapted because it asserted the very false positive being closed". Against `origin/main` the test file is a pure append (+348 / -0, single hunk). The two lines rewritten in commit b55d497 belonged to a test this branch itself added in 9b92b34.No action needed — recorded because it changes how the diff should be read: no pre-existing contract was renegotiated.

Per-file diffs

Click to expand.

prism/Services/ClipboardService.swift Modified +225 / -33
diff --git a/prism/Services/ClipboardService.swift b/prism/Services/ClipboardService.swiftindex b19e021..9f1a029 100644--- a/prism/Services/ClipboardService.swift+++ b/prism/Services/ClipboardService.swift@@ -28,24 +28,29 @@ enum ClipboardService {     /// Requirement 2.5: Same limit as file-based documents.     static let maxContentSize = 10_485_760 +    /// Maximum whitespace-separated tokens a mermaid diagram declaration may carry.+    ///+    /// The type keyword plus at most one direction or modifier token+    /// (`flowchart LR`, `pie showData`). See `declaredDiagramType`.+    private static let maxDeclarationTokenCount = 2+     // MARK: - Mermaid Detection Patterns -    /// Regex patterns for mermaid-specific syntax that wouldn't appear in prose.+    /// Strong mermaid syntax patterns: operator shapes that do not occur in prose.     ///-    /// These patterns detect:-    /// - Arrow operators: `-->`, `A---B`, `-.->`, `==>`, `-->>`, `->>`, `->>`-    /// - Node definitions with identifiers: `A[text]`, `A(text)`, `A{text}`, `A((text))`, `A[[text]]`-    /// - Relationship syntax: `:::`, `|text|`-    /// - Sequence diagram arrows: `->>`, `-->>`, `-x`, `--x`-    /// - Class diagram: `<|--`, `*--`, `o--`-    /// - ER diagram: `||--o{`, `}|--|{`-    /// - Pie chart: `"label": number` pattern-    /// - Gantt/Timeline: `section`, `title` keywords with content-    /// - Journey: `section` keyword+    /// A line matching any of these is treated as mermaid syntax unconditionally —+    /// including lines that end in sentence punctuation. Sequence-diagram messages+    /// routinely do (`Alice->>Bob: Are you there?`, `Bob-->>Alice: Yes, I am.`), and+    /// the arrow is unambiguous evidence regardless of how the label ends (T-1840).     ///-    /// Note: Node definition patterns require a preceding identifier to avoid matching-    /// prose with parentheses, markdown links [text](url), or JSON snippets {key: value}.-    private static let mermaidSyntaxPatterns: [String] = [+    /// These patterns detect:+    /// - Flowchart arrows: `-->`, `A---B`, `-.->`, `==>`+    /// - Sequence diagram arrows: `->>`, `-->>`+    /// - Class/style syntax: `:::`+    /// - Class diagram relationships: `<|--`, `*--`, `o--`+    /// - ER diagram relationships: `||--`, `}o--`, `}|--`, `--o{`, `--|{`+    /// - Pie chart: `"label": number` value lines+    private static let strongMermaidSyntaxPatterns: [String] = [         // Flowchart arrows (most common, placed first for early exit)         #"-->"#,           // Flowchart arrow         #"\w+[^\S\n]*---[^\S\n]*\w+"#, // Solid line with node identifiers (A---B) on same line@@ -54,17 +59,8 @@ enum ClipboardService {         // Sequence diagram arrows         #"->>"#,           // Async arrow         #"-->>"#,          // Async dashed arrow-        #"-x"#,            // Cross-        #"--x"#,           // Dashed cross-        // Node definitions - require preceding identifier to avoid matching prose-        #"\w\[[^\]]+\]"#,  // Square bracket nodes: A[text] (not markdown links)-        #"\w\([^)]+\)"#,   // Parenthesis nodes: A(text) (not prose parentheses)-        #"\w\{[^}]+\}"#,   // Curly brace nodes: A{text} (not JSON snippets)-        #"\(\([^)]+\)\)"#, // Double parenthesis ((text)) - unique to mermaid-        #"\[\[[^\]]+\]\]"#, // Double brackets [[text]] - unique to mermaid         // Class/style syntax         #":::"#,           // Class definition-        #"\|[^|]+\|"#,     // Edge labels |text|         // Class diagram relationships         #"<\|--"#,         // Inheritance         #"\*--"#,          // Composition@@ -77,18 +73,97 @@ enum ClipboardService {         #"--\|\{"#,        // ER one-to-many mandatory         // Pie chart syntax         #""[^"]+"\s*:\s*\d+(?:\.\d+)?"#, // "label": number (integer or float)+    ]++    /// Weak mermaid *shape* patterns: node and edge shapes that ordinary prose can+    /// also produce.+    ///+    /// These only count as mermaid syntax on lines that do not end in sentence+    /// punctuation (see `endsInSentencePunctuation`), because each shape has a+    /// common prose double: a numbered citation (`results[3]`), an unspaced+    /// parenthetical (`the tool(a favorite)`), a JSON snippet (`{key: value}`), a+    /// wiki-link (`[[Page]]`), a footnote marker (`[*]`), a table/pipe fragment+    /// (`|x|`), or a hyphenated word containing `-x` (`non-xml`) (T-1840).+    ///+    /// These patterns detect:+    /// - Node definitions with identifiers: `A[text]`, `A(text)`, `A{text}`, `A((text))`, `A[[text]]`+    /// - Member-block openers: `class Animal{`, `CUSTOMER {` (see below)+    /// - Sequence diagram crosses: `-x`, `--x`+    /// - Edge labels: `|text|`+    /// - State diagram start/end: `[*]`+    ///+    /// Note: Node definition patterns require a preceding identifier to avoid matching+    /// prose with parentheses, markdown links [text](url), or JSON snippets {key: value}.+    ///+    /// The member-block opener is what makes class, ER, and state diagrams detectable+    /// when their body spans lines. `\w\{[^}]+\}` only matches a brace pair closed on+    /// the same line, so a fields-only `class Animal{ … }` — the form in mermaid's own+    /// documentation — carries no other syntax for detection to land on once matching+    /// became per-line (T-1840).+    private static let weakMermaidShapePatterns: [String] = [+        // Sequence diagram crosses (`-x` is a substring of hyphenated words)+        #"-x"#,            // Cross+        #"--x"#,           // Dashed cross+        // Node definitions - require preceding identifier to avoid matching prose+        #"\w\[[^\]]+\]"#,  // Square bracket nodes: A[text] (not markdown links)+        #"\w\([^)]+\)"#,   // Parenthesis nodes: A(text) (not prose parentheses)+        #"\w\{[^}]+\}"#,   // Curly brace nodes: A{text} (not JSON snippets)+        #"\(\([^)]+\)\)"#, // Double parenthesis ((text)) - unique to mermaid+        #"\[\[[^\]]+\]\]"#, // Double brackets [[text]] - unique to mermaid+        // Edge labels+        #"\|[^|]+\|"#,     // Edge labels |text|         // State diagram         #"\[\*\]"#,        // Start/end state [*]-        // Gantt/Timeline/Journey keywords (at start of line)-        #"(?m)^\s*section\s+"#, // section keyword-        #"(?m)^\s*title\s+"#,   // title keyword-        #"(?m)^\s*dateFormat\s+"#, // dateFormat keyword+        // Member-block opener: identifier followed by an unclosed brace (class Animal{)+        #"\w[^\S\n]*\{[^\S\n]*$"#,+    ]++    /// Weak mermaid *directive* patterns: the Gantt/Timeline/Journey line keywords+    /// `section`, `title`, and `dateFormat`, each paired with the diagram types it+    /// actually belongs to.+    ///+    /// Also weak — an ordinary sentence can start with "section" or "title"+    /// ("title case is preferred for headings.") — so, like the shape patterns, a+    /// directive is suppressed on a line ending in sentence punctuation. Unlike+    /// them it gets one narrow exemption: `?` and `!` are idiomatic in genuine+    /// diagram titles and sections (`title Are we on track?`, `section Ship it!`),+    /// so a `?`/`!`-terminated directive still counts when the text opens with a+    /// bare declaration of a diagram type that admits that directive (see+    /// `declaredDiagramType`). A trailing period never counts: a period-terminated+    /// title reads as prose in every diagram type.+    ///+    /// The type pairing is what keeps the exemption honest. `title` and `section`+    /// only mean anything in gantt, journey, timeline, and (for `title`) pie;+    /// `dateFormat` only in gantt. So `gantt` + `title Are we on track?` is+    /// detected, while a paragraph opening "Timeline mapping is useful" and later+    /// carrying "section 3 covers this?" is not — its opening line is prose, not a+    /// declaration (T-1840).+    private static let weakMermaidDirectives: [(pattern: String, diagramTypes: Set<String>)] = [+        // section keyword — gantt, journey, and timeline group their entries by section+        (#"(?m)^\s*section\s+"#, ["Gantt Chart", "User Journey", "Timeline"]),+        // title keyword — the same three plus pie+        (#"(?m)^\s*title\s+"#, ["Gantt Chart", "User Journey", "Timeline", "Pie Chart"]),+        // dateFormat keyword — gantt only+        (#"(?m)^\s*dateFormat\s+"#, ["Gantt Chart"]),     ]      /// Pre-compiled regex patterns for mermaid syntax detection.     /// Compiled once at load time to avoid repeated compilation on each paste operation.-    private static let compiledMermaidPatterns: [NSRegularExpression] = {-        mermaidSyntaxPatterns.compactMap { try? NSRegularExpression(pattern: $0) }+    private static let compiledStrongMermaidPatterns: [NSRegularExpression] = {+        strongMermaidSyntaxPatterns.compactMap { try? NSRegularExpression(pattern: $0) }+    }()++    private static let compiledWeakShapePatterns: [NSRegularExpression] = {+        weakMermaidShapePatterns.compactMap { try? NSRegularExpression(pattern: $0) }+    }()++    private static let compiledWeakDirectives: [(regex: NSRegularExpression, diagramTypes: Set<String>)] = {+        weakMermaidDirectives.compactMap { directive in+            guard let regex = try? NSRegularExpression(pattern: directive.pattern) else {+                return nil+            }+            return (regex: regex, diagramTypes: directive.diagramTypes)+        }     }()      /// Checks if clipboard contains text without reading content.@@ -219,12 +294,129 @@ enum ClipboardService {     /// that wouldn't normally appear in prose text. Uses pre-compiled regex     /// patterns for performance.     ///+    /// Matching is done per line, in three tiers (T-1840). Strong patterns (arrows,+    /// relationship operators, pie's `"label": number`) match on every line — a+    /// sequence-diagram message like `Alice->>Bob: Are you there?` ends in sentence+    /// punctuation but the arrow is unambiguous. The two weak tiers also occur in+    /// ordinary prose, so both are suppressed on a line ending in sentence+    /// punctuation (`.`, `?`, `!`):+    ///+    /// - Shape patterns (bracket/paren node shapes, edge labels, state markers) get+    ///   no exemption, because their prose doubles — a numbered citation+    ///   (`results[3]`), a parenthetical aside (`the tool(a favorite)`) — appear in+    ///   questions and exclamations just as readily as in declarative sentences.+    /// - Directive patterns (`title`/`section`/`dateFormat`) get one narrow+    ///   exemption: a `?`/`!`-terminated directive still counts when the text opens+    ///   with a bare declaration of a diagram type that admits it (see+    ///   `declaredDiagramType` and `weakMermaidDirectives`). So `gantt` followed by+    ///   `title Are we on track?` is detected, while a paragraph opening "Timeline+    ///   mapping is useful" and later carrying "section 3 covers this?" is not.+    ///   A trailing period is never exempt in either tier.+    ///+    /// See the fixture diagrams and prose fixtures in ClipboardServiceTests.+    ///+    /// Per-line matching scopes the negated-character-class patterns (`[^\]]`,+    /// `[^)]`, `[^}]`, `[^"]`) to a single line; when the whole text was matched in+    /// one pass they could span a literal newline (e.g. `A[first\nsecond]`).+    ///+    /// For the bracket, paren, and quote shapes that corner is out of scope: mermaid+    /// labels use `<br/>` (or backtick-quoted markdown strings) for line breaks, a+    /// raw newline inside a plain bracket label is not valid diagram source, and any+    /// real diagram has other single-line syntax for detection to land on.+    ///+    /// The brace shape is NOT such a corner and must not be dismissed as one. A+    /// multi-line `{ … }` is not a label — it is the member block of a class, ER, or+    /// state diagram, and a fields-only body (`class Animal{` / `+int age` / `}`) has+    /// no other syntax on any line. `\w\{[^}]+\}` stopped matching it the moment+    /// matching went per-line, so `weakMermaidShapePatterns` carries an explicit+    /// member-block-opener pattern to keep those three diagram types detected.+    ///     /// - Parameter text: The text to check.-    /// - Returns: True if any mermaid syntax pattern is found.+    /// - Returns: True if any strong pattern is found on any line, or any weak+    ///            pattern on a line its punctuation gate lets through.     static func containsMermaidSyntax(_ text: String) -> Bool {-        let nsRange = NSRange(text.startIndex..., in: text)-        return compiledMermaidPatterns.contains { regex in-            regex.firstMatch(in: text, range: nsRange) != nil+        let declaredType = declaredDiagramType(in: text)+        return text.components(separatedBy: .newlines).contains { line in+            let trimmed = line.trimmingCharacters(in: .whitespaces)+            guard !trimmed.isEmpty else {+                return false+            }+            let nsRange = NSRange(trimmed.startIndex..., in: trimmed)+            if compiledStrongMermaidPatterns.contains(where: { regex in+                regex.firstMatch(in: trimmed, range: nsRange) != nil+            }) {+                return true+            }+            guard !endsInSentencePunctuation(trimmed) else {+                // The one exemption: a `?`/`!`-terminated directive whose keyword+                // belongs to the diagram type the text actually declares.+                return trimmed.last != "." && compiledWeakDirectives.contains { directive in+                    directive.regex.firstMatch(in: trimmed, range: nsRange) != nil+                        && declaredType.map(directive.diagramTypes.contains) == true+                }+            }+            if compiledWeakDirectives.contains(where: { directive in+                directive.regex.firstMatch(in: trimmed, range: nsRange) != nil+            }) {+                return true+            }+            return compiledWeakShapePatterns.contains { regex in+                regex.firstMatch(in: trimmed, range: nsRange) != nil+            }+        }+    }++    /// The mermaid diagram type the text *declares*, or nil when its opening line is+    /// not a declaration.+    ///+    /// `MermaidTypeParser.parse` inspects only the first word of the first+    /// non-comment line, which is deliberately permissive — it exists to label+    /// source already known to be a diagram. That permissiveness is the whole reason+    /// prose can reach the syntax check at all: "Timeline for the migration project+    /// remains aggressive." parses as `Timeline`, because "Graph", "Pie", "Journey",+    /// "Timeline", "Kanban", "Architecture", and "Block" are ordinary English words+    /// as well as diagram keywords (T-1840).+    ///+    /// A real declaration is the keyword on its own, optionally followed by a single+    /// direction or modifier token (`gantt`, `journey`, `flowchart LR`,+    /// `pie showData`) — never a sentence. Requiring that shape before granting the+    /// directive tier its `?`/`!` exemption is what separates a punctuated gantt+    /// title from a hard-wrapped paragraph that merely starts with a keyword.+    ///+    /// - Parameter text: The full text being checked.+    /// - Returns: The declared diagram type name, or nil if the opening line is not+    ///            a bare declaration of a recognised type.+    private static func declaredDiagramType(in text: String) -> String? {+        for line in text.components(separatedBy: .newlines) {+            let trimmed = line.trimmingCharacters(in: .whitespaces)+            if trimmed.isEmpty || trimmed.hasPrefix("%%") {+                continue+            }+            guard trimmed.split(whereSeparator: \.isWhitespace).count <= maxDeclarationTokenCount else {+                return nil+            }+            let type = MermaidTypeParser.parse(trimmed)+            return type == "Diagram" ? nil : type+        }+        return nil+    }++    /// Checks whether a (trimmed, non-empty) line ends in any sentence-terminating+    /// punctuation.+    ///+    /// Mermaid syntax lines (`A[Node]`, `title My Diagram`, `"Label": 30`, ...) are+    /// terse and do not usually end with sentence-terminating punctuation, while+    /// ordinary prose sentences almost always do — a cheap discriminator that does+    /// not require parsing English (T-1840). It gates both weak tiers: the shape+    /// patterns unconditionally, and the directive patterns unless the text opens+    /// with a declaration of a type that admits the directive.+    ///+    /// - Parameter line: A trimmed, non-empty line.+    /// - Returns: True if the line ends with `.`, `?`, or `!`.+    private static func endsInSentencePunctuation(_ line: String) -> Bool {+        guard let last = line.last else {+            return false         }+        return last == "." || last == "?" || last == "!"     } }
prismTests/ClipboardServiceTests.swift Modified +348 / -0
diff --git a/prismTests/ClipboardServiceTests.swift b/prismTests/ClipboardServiceTests.swiftindex f2ba6fa..954dcc2 100644--- a/prismTests/ClipboardServiceTests.swift+++ b/prismTests/ClipboardServiceTests.swift@@ -514,4 +514,352 @@ struct ClipboardServiceTests {         let result = ClipboardService.wrapMermaidIfNeeded(input)         #expect(result == input, "Prose with JSON snippets should not be wrapped")     }++    // MARK: - T-1840: Prose False Positive Regression Tests+    //+    // Bug: wrapMermaidIfNeeded's heuristics were loose enough that ordinary prose+    // with numbered citations, parenthetical asides, or a "section"/"title" line+    // satisfied every gate (first-line keyword + syntax pattern + 2+ lines) and got+    // auto-wrapped in a mermaid codefence. Each case below starts with a line whose+    // first word is a genuine mermaid keyword that also happens to be an ordinary+    // English word ("graph", "kanban", "pie", "journey", "timeline", "architecture",+    // "block"), which is what let it through the MermaidTypeParser gate.++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a numbered citation")+    func wrapMermaidIfNeededDoesNotWrapProseWithNumberedCitation() {+        // "applications[3]" satisfies the \w\[...\] node pattern exactly like a+        // real mermaid node (e.g. A[Start]) would.+        let input = """+            Graph theory has broad applications[3] in computer science.+            Many results[12] extend to infinite graphs as well.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with numbered citations should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a keyword-adjacent parenthetical")+    func wrapMermaidIfNeededDoesNotWrapProseWithKeywordAdjacentParenthetical() {+        // "used(for tracking work)" satisfies the \w\(...\) node pattern.+        let input = """+            Kanban boards such as Trello are widely used(for tracking work).+            Teams report increased visibility into their backlog.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with a keyword-adjacent parenthetical should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose starting with a keyword and a parenthetical aside")+    func wrapMermaidIfNeededDoesNotWrapProseWithParentheticalAside() {+        let input = """+            Pie is my favorite dessert(specifically apple pie).+            Many people prefer it over cake for celebrations.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with a parenthetical aside should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose containing a lowercase 'section' sentence")+    func wrapMermaidIfNeededDoesNotWrapProseWithSectionSentence() {+        // "section 3 of the report..." satisfies the `^\s*section\s+` gantt/journey+        // directive pattern even though it's an ordinary sentence.+        let input = """+            Timeline for the migration project remains aggressive.+            section 3 of the report covers rollout details and risk.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with a 'section ...' sentence should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose containing a lowercase 'title' sentence")+    func wrapMermaidIfNeededDoesNotWrapProseWithTitleSentence() {+        // "title case is preferred..." satisfies the `^\s*title\s+` directive pattern.+        let input = """+            Architecture reviews happen quarterly at our company.+            title case is preferred for all section headings in docs.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with a 'title ...' sentence should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a bracketed citation on both lines")+    func wrapMermaidIfNeededDoesNotWrapProseWithBracketedCitationOnBothLines() {+        let input = """+            Block quotes are useful[1] for citing other authors.+            This technique appears frequently in academic writing[2].+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with bracketed citations should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose starting with 'journey' and a parenthetical")+    func wrapMermaidIfNeededDoesNotWrapProseWithJourneyParenthetical() {+        let input = """+            Journey mapping(a UX technique) helps teams understand users.+            It is often used early in the design process.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Prose with a 'journey' opener and a parenthetical should not be wrapped")+    }++    // The same prose shapes, terminated with `?`/`!` instead of a period. The weak+    // *shape* patterns (citations, parentheticals) must stay gated on any sentence+    // punctuation — only the title/section/dateFormat directives get the `?`/`!`+    // exemption, so questions and exclamations cannot reopen this false-positive+    // class through a node-shape match.++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a numbered citation ending in a question mark")+    func wrapMermaidIfNeededDoesNotWrapQuestionTerminatedProseWithCitation() {+        let input = """+            Graph theory has broad applications[3] in computer science?+            Many results[12] extend to infinite graphs as well.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Question-terminated prose with numbered citations should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a parenthetical ending in an exclamation mark")+    func wrapMermaidIfNeededDoesNotWrapExclamationTerminatedProseWithParenthetical() {+        let input = """+            Kanban boards such as Trello are widely used(for tracking work)!+            Teams report increased visibility into their backlog.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Exclamation-terminated prose with a parenthetical should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap 'journey' prose with a parenthetical ending in a question mark")+    func wrapMermaidIfNeededDoesNotWrapQuestionTerminatedJourneyParenthetical() {+        let input = """+            Journey mapping(a UX technique) helps teams understand users?+            It is often used early in the design process.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Question-terminated 'journey' prose with a parenthetical should not be wrapped")+    }++    // Legitimate diagram shapes the syntax-pattern heuristic exists for must still be+    // detected — these have no arrow operator, so they rely entirely on the+    // title/section/dateFormat/quoted-number patterns the fix above narrows.++    @Test("wrapMermaidIfNeeded still wraps pie chart (no arrows, relies on quoted-number pattern)")+    func wrapMermaidIfNeededStillWrapsPieChartWithoutArrows() {+        let input = "pie\ntitle My Pie\n\"A\": 30\n\"B\": 70"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps gantt chart (no arrows, relies on title/dateFormat)")+    func wrapMermaidIfNeededStillWrapsGanttWithoutArrows() {+        let input = "gantt\ntitle A Gantt Diagram\ndateFormat YYYY-MM-DD"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps user journey (no arrows, relies on title/section)")+    func wrapMermaidIfNeededStillWrapsJourneyWithoutArrows() {+        let input = "journey\ntitle My working day\nsection Go to work"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps timeline (no arrows, relies on title pattern)")+    func wrapMermaidIfNeededStillWrapsTimelineWithoutArrows() {+        let input = "timeline\ntitle History\n2000 : Event A"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    // Punctuated diagram lines: the sentence-like gate must never suppress a line+    // carrying a strong operator pattern (arrows, relationships, pie values), and+    // `?`/`!` endings — idiomatic in genuine titles and labels — must not gate the+    // weak directive patterns either. Both shapes were detected on main and briefly+    // regressed when the gate skipped every punctuated line before matching.++    @Test("wrapMermaidIfNeeded still wraps sequence diagram whose messages end in punctuation")+    func wrapMermaidIfNeededStillWrapsPunctuatedSequenceDiagram() {+        let input = "sequenceDiagram\nAlice->>Bob: Are you there?\nBob-->>Alice: Yes, I am!"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps sequence diagram whose messages end in periods")+    func wrapMermaidIfNeededStillWrapsPeriodTerminatedSequenceDiagram() {+        let input = "sequenceDiagram\nClient->>Server: Please respond.\nServer-->>Client: Here you go."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps gantt chart whose title ends in a question mark")+    func wrapMermaidIfNeededStillWrapsPunctuatedGantt() {+        let input = "gantt\ntitle Are we on track?\nsection Can we ship?"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps user journey whose title and section end in punctuation")+    func wrapMermaidIfNeededStillWrapsPunctuatedJourney() {+        let input = "journey\ntitle Are we on track?\nsection Is this done?"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("containsMermaidSyntax matches strong operator patterns on sentence-like lines")+    func containsMermaidSyntaxMatchesStrongPatternsOnSentenceLikeLines() {+        #expect(ClipboardService.containsMermaidSyntax("Alice->>Bob: Are you there?") == true)+        #expect(ClipboardService.containsMermaidSyntax("Bob-->>Alice: Yes, I am!") == true)+        #expect(ClipboardService.containsMermaidSyntax("Server-->>Client: Here you go.") == true)+        #expect(ClipboardService.containsMermaidSyntax("A --> B: Done.") == true)+    }++    @Test("containsMermaidSyntax ignores a matching pattern on a sentence-like line")+    func containsMermaidSyntaxIgnoresSentenceLikeLine() {+        #expect(ClipboardService.containsMermaidSyntax("Results improved[3] significantly.") == false)+        #expect(ClipboardService.containsMermaidSyntax("section 3 covers rollout details and risk.") == false)+        #expect(ClipboardService.containsMermaidSyntax("title case is preferred for headings.") == false)+    }++    @Test("containsMermaidSyntax still matches the same pattern without terminal punctuation")+    func containsMermaidSyntaxStillMatchesWithoutTerminalPunctuation() {+        #expect(ClipboardService.containsMermaidSyntax("Results[3] improved significantly") == true)+        #expect(ClipboardService.containsMermaidSyntax("section Go to work") == true)+        #expect(ClipboardService.containsMermaidSyntax("title My Diagram") == true)+    }++    @Test("containsMermaidSyntax ignores weak shape patterns on question- and exclamation-terminated lines")+    func containsMermaidSyntaxIgnoresShapePatternsOnPunctuatedLines() {+        #expect(ClipboardService.containsMermaidSyntax("Graph theory has broad applications[3] in computer science?") == false)+        #expect(ClipboardService.containsMermaidSyntax("Trello is widely used(for tracking work)!") == false)+        #expect(ClipboardService.containsMermaidSyntax("Journey mapping(a UX technique) helps teams understand users?") == false)+    }++    @Test("containsMermaidSyntax still matches directive patterns on question- and exclamation-terminated lines")+    func containsMermaidSyntaxMatchesDirectivesOnPunctuatedLines() {+        // The `?`/`!` exemption is granted by the declaration line, so these carry+        // one: gantt and journey both admit `title` and `section`.+        #expect(ClipboardService.containsMermaidSyntax("gantt\ntitle Are we on track?") == true)+        #expect(ClipboardService.containsMermaidSyntax("journey\nsection Ship it!") == true)+    }++    // The directive tier's `?`/`!` exemption is correlated with the declared diagram+    // type, so prose cannot claim it. A hard-wrapped paragraph whose first line+    // merely *starts* with a mermaid keyword is not a declaration, and a keyword+    // that is a declaration only exempts the directives its diagram type uses.++    @Test("containsMermaidSyntax ignores directive keywords in prose with no diagram declaration")+    func containsMermaidSyntaxIgnoresDirectiveProseWithoutDeclaration() {+        #expect(ClipboardService.containsMermaidSyntax("title case is preferred for headings, right?") == false)+        #expect(ClipboardService.containsMermaidSyntax("section 3 of the report covers revenue, doesn't it?") == false)+        #expect(ClipboardService.containsMermaidSyntax("Timeline mapping is useful\nsection 3 covers this?") == false)+    }++    @Test("containsMermaidSyntax ignores a punctuated directive the declared diagram type does not use")+    func containsMermaidSyntaxIgnoresPunctuatedDirectiveForeignToDeclaredType() {+        // Flowcharts have no `section`, and only gantt uses `dateFormat`.+        #expect(ClipboardService.containsMermaidSyntax("flowchart\nsection Ship it!") == false)+        #expect(ClipboardService.containsMermaidSyntax("journey\ndateFormat is confusing?") == false)+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a 'title' sentence ending in a question mark")+    func wrapMermaidIfNeededDoesNotWrapQuestionTerminatedProseWithTitleSentence() {+        let input = """+            Architecture reviews happen quarterly at our company.+            title case is preferred for all section headings, right?+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Question-terminated prose with a 'title ...' sentence should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap prose with a 'section' sentence ending in an exclamation mark")+    func wrapMermaidIfNeededDoesNotWrapExclamationTerminatedProseWithSectionSentence() {+        let input = """+            Timeline for the migration project remains aggressive.+            section 3 of the report covers rollout details and risk!+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "Exclamation-terminated prose with a 'section ...' sentence should not be wrapped")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap a keyword-led paragraph whose 'section' line asks a question")+    func wrapMermaidIfNeededDoesNotWrapQuestionTerminatedSectionInKeywordParagraph() {+        // The reviewer's shape: "Timeline" opens the paragraph, so MermaidTypeParser+        // reports Timeline — a type that genuinely uses `section`. Only the opening+        // line failing to be a declaration keeps this out of the codefence.+        let input = """+            Timeline mapping is useful+            section 3 covers this?+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A prose opener is not a diagram declaration and must not exempt the directive")+    }++    // Diagrams whose only mermaid syntax is a member block spanning several lines.+    // `\w\{[^}]+\}` requires the brace pair to close on the same line, so moving to+    // per-line matching silently un-detected every fields-only class, ER, and state+    // diagram — the exact form used in mermaid's own documentation. These pin the+    // member-block-opener pattern that keeps them detected.++    @Test("wrapMermaidIfNeeded still wraps a class diagram whose member block spans lines")+    func wrapMermaidIfNeededStillWrapsMultiLineClassBody() {+        let input = "classDiagram\nclass Animal{\n +int age\n +String gender\n}"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps a class diagram with a space before the member block")+    func wrapMermaidIfNeededStillWrapsSpacedClassBody() {+        let input = "classDiagram\nclass Animal {\n +int age\n}"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps an ER diagram whose attribute block spans lines")+    func wrapMermaidIfNeededStillWrapsMultiLineERBody() {+        let input = "erDiagram\nCUSTOMER{\nstring name\nstring email\n}"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded still wraps a state diagram whose composite state spans lines")+    func wrapMermaidIfNeededStillWrapsCompositeState() {+        let input = "stateDiagram-v2\nstate First{\n Second\n}"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("containsMermaidSyntax does NOT treat a prose line ending in a brace as a member block")+    func containsMermaidSyntaxIgnoresProseEndingInBrace() {+        // The brace must follow an identifier directly; `(x) {` and `else {` after a+        // non-word character do not qualify, so pasted pseudo-code stays prose.+        #expect(ClipboardService.containsMermaidSyntax("Here is some pseudo code: if (x) {") == false)+    }++    // The directive tier's type pairing, exercised for every entry. Only `title`+    // admits Pie Chart; `section` does not, and `dateFormat` is gantt-only. Without+    // these the Timeline and Pie Chart entries are never consulted, so a rename of+    // either display name would disable the exemption with a green suite.++    @Test("containsMermaidSyntax honours the declared type for every directive pairing")+    func containsMermaidSyntaxHonoursDirectiveTypePairings() {+        #expect(ClipboardService.containsMermaidSyntax("timeline\ntitle What happened?") == true)+        #expect(ClipboardService.containsMermaidSyntax("timeline\nsection Ship it!") == true)+        #expect(ClipboardService.containsMermaidSyntax("pie\ntitle Where does time go?") == true)+        #expect(ClipboardService.containsMermaidSyntax("gantt\ndateFormat YYYY?") == true)+        // Pie has no `section`, so the exemption must not extend to it.+        #expect(ClipboardService.containsMermaidSyntax("pie\nsection Ship it!") == false)+    }++    @Test("containsMermaidSyntax never exempts a period-terminated directive")+    func containsMermaidSyntaxNeverExemptsPeriodTerminatedDirective() {+        // Pins the `trimmed.last != "."` clause: a real declaration is present and the+        // type admits the directive, so only the trailing period keeps these out.+        #expect(ClipboardService.containsMermaidSyntax("gantt\ntitle Ship it.") == false)+        #expect(ClipboardService.containsMermaidSyntax("journey\nsection Go to work.") == false)+    }++    @Test("declaration recognition accepts a two-token declaration and skips comments")+    func declarationRecognitionAcceptsTwoTokensAndSkipsComments() {+        // Pins maxDeclarationTokenCount = 2 and the `%%` skip in declaredDiagramType.+        #expect(ClipboardService.containsMermaidSyntax("pie showData\ntitle Are we on track?") == true)+        #expect(ClipboardService.containsMermaidSyntax("%% a comment\ngantt\ntitle Are we on track?") == true)+    } }
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9938073..6a45f39 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Pasting ordinary prose into clipboard mode no longer sometimes rewrites it as a broken mermaid diagram (T-1840). Prism auto-wraps clipboard text that looks like raw mermaid diagram source in a codefence so it renders correctly; the check for "looks like mermaid source" was loose enough that a paragraph with a numbered citation (`results[3]`), a parenthetical aside written without a leading space (`the tool(a favorite)`), or a sentence that happened to start with the word "section" or "title" could satisfy it, as long as the paragraph's first word was also a mermaid keyword — "Graph", "Pie", "Journey", "Timeline", "Kanban", "Architecture", and "Block" are all both. The check now weighs each line by how much the syntax on it could plausibly be prose. Operator syntax prose never produces — arrows, class and ER relationship operators, pie's `"label": number` lines — counts wherever it appears, even on a line ending in sentence punctuation, because a sequence-diagram message routinely does (`Alice->>Bob: Are you there?`). The ambiguous shapes prose also produces — bracketed or parenthesised tokens, edge-label pipes, and lines starting with "section", "title", or "dateFormat" — are ignored on any line that ends in `.`, `?`, or `!`, which is what a sentence does and a diagram label usually doesn't. Genuine diagram titles and sections *can* end in `?` or `!` (`title Are we on track?`), so those keywords get one narrow exemption: they still count when the text opens with an actual diagram declaration — the type keyword on its own, as in `gantt` or `journey`, not merely a paragraph whose first word happens to be one — and that declared type is one the keyword belongs to (`title` in gantt, journey, timeline, and pie; `section` in the first three of those; `dateFormat` in gantt alone). So a real gantt or journey whose title asks a question still wraps, while a paragraph opening "Timeline mapping is useful" and later carrying "section 3 covers this?" does not. A trailing full stop is never exempt in any diagram type, which is the one place detection deliberately narrowed: a gantt, journey, or pie chart whose only `title` or `section` line ends in a full stop is no longer auto-wrapped, and has to be fenced by hand. Every other real diagram shape is still detected, including the types with no arrow operator to fall back on (pie, gantt, journey, timeline) and the class, ER, and state diagrams whose member block spans several lines. - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.

Things to double-check

The two failures in the full unit sweep were load flakes — confirmed.

A full prismTests sweep (4308 tests) showed 2 failures: WebScrollabilityReportingTests/A sustained trigger burst still reports within the debounce's maximum wait and WebScrollNavigationTests/visibleBlock is suppressed while a programmatic scroll is in flight. Both are timing/debounce assertions in the WebKit scroll path, which this diff does not touch — it changes only ClipboardService, a leaf with no production callers beyond validateContent. That sweep ran concurrently with two builds on the same machine. Re-running both classes in isolation: 25/25 pass. Load flakes, not a real failure — but they are timing-sensitive enough to fail under parallel load, which is worth knowing when reading CI output.

Both builds carry two pre-existing warnings.

make build-ios and make build-macos both succeed but emit main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context; this is an error in the Swift 6 language mode. ImageDimension lives in prism/Models/MarkdownBlock.swift, untouched by this diff, so these are inherited from main — but the project's pre-push bar is zero warnings, so they will need their own ticket.

The fixes applied in this review are uncommitted.

Three files are modified in the working tree and not yet committed: the member-block-opener pattern and corrected doc comment in ClipboardService.swift, two factual corrections in CHANGELOG.md, and 8 new tests in ClipboardServiceTests.swift. Targeted suite is 115/115 green and SwiftLint reports 0 violations across 528 files, but they need committing before the push.

The residual false-positive class is real and undocumented.

Unpunctuated prose still wraps: hard-wrapped paragraphs, headings, bullet lists, table rows, and sentences closed by a quote. ClipboardServiceTests.swift:723 asserts containsMermaidSyntax("Results[3] improved significantly") == true — that is the bug's own shape minus its full stop, now pinned as an invariant that will fight the next tightening. Worth a comment on that assertion saying it records a known residual rather than a desired contract.