prism commits 4 files 7 touched lines +1192 / -28 differential 156 rows scored HEAD worse than main 37 HEAD better than main 36 targeted tests 146 / 146 pass lint / build / isolation 0 / 0 warn / 43 OK

Pre-push review (round 2): T-1840/bugfix-clipboard-prose-mermaid

PR #396 · Transit T-1840 — “Clipboard prose can be rewritten as a Mermaid diagram”, reopened three times. Second independent audit, against git diff origin/main...HEAD, with the differential harness from the first review re-run and extended: 156 scored fixtures (the branch's own 82-row corpus plus 74 reviewer probes) compiled out of tree at both origin/main and HEAD, so every verdict below is a measured before/after rather than a reading of the regex.

At a glance

  • Round-1 blocker 2 is closed, measured. The bounded, ^-anchored, per-line rewrite of the sequence pattern is effective: 32 KB space-free arrow line 16,661 ms → 8.7 ms (main: 8.2 ms); 100 KB 23.0 ms (main: 22.0 ms). Flat under sequenceDiagram, timeline, gantt and classDiagram alike.
  • Round-1 majors are closed. classDiagram\nAnimal <|-- Duck : implements a quack behavior., the ER and state equivalents, and mermaid's own pie title Pets adopted by volunteers all wrap again. gitGraph, sankey-beta, packet-beta and kanban now wrap where they never did.
  • Blocker — 37 rows regress. Every one is prose that origin/main leaves alone and HEAD wraps. Two grammars carry it: timelinePeriod's unterminated month alternative (10 rows measured; class is every ≤9-letter capitalised word starting Jan…Dec) and the gantt metadata alternation, whose three branches are all ordinary English (after, 1990s/100m/30s, a date written in a sentence).
  • Major — the new guard is structurally inert where it is needed. matchesLineLevelGrammar sentence-tests line[..<match.upperBound]. For Timeline that ends at the first non-space after the colon; for Gantt at the date token; for Journey at the second colon. The segment is short because the regex stops there, not because the line is a diagram.
  • Major — a two-word prose opener is more permissive than a one-word one, on both revisions. Any single unrecognised tail token makes the declaration “qualified”, which this PR uses to switch the sentence-shape gate off entirely. Block quotes\nThe block A --> B is described below. wraps — the reported repro, one word longer. Not a regression; not closed either, and the corpus has no row for it.
  • Minor — two performance claims over-state what was fixed. CHANGELOG and decision log both say cost now grows with size rather than its square. It does for the arrow shape; a space-free run of letters ending in : is still 4×-per-doubling (8 KB = 4.2 s) on both revisions, via the pre-existing \w+[^\S\n]*---[^\S\n]*\w+. Pre-existing, not a regression — but the sentence asserts a property the code does not have, and the new growth guard's only shape cannot reach it.
  • Verified untouched. MermaidTypeParser.parse has zero non-comment changes; its two in-document callers (WebDocumentMessageRouter.swift:141, BlockHTMLEmitter.swift:515) consume already-fenced source. wrapMermaidIfNeeded/containsMermaidSyntax/declaredDiagramType have no production caller outside ClipboardService. The fence / in-document render path is not reachable from this diff.
  • Gates, all green and all verified real. make lint 0 violations / 557 files · make build-macos succeeded, 0 warnings · make verify-test-isolation 43 OK · targeted ClipboardServiceTests + MermaidTypeParserTests + ClipboardMermaidDetectionCorpusTests + ClipboardServiceBareDeclarationTests = 146 executed, 146 passed (confirmed through Tools/check-test-results.sh, so the count is not fictional).

Verdict

Needs fixes

Commit abc1d5de genuinely closes both round-1 blockers and both round-1 majors. The quadratic sequence-message pattern is gone — a 32 KB space-free arrow line went 16,661 ms → 8.7 ms, which is origin/main's own 8.2 ms; at 100 KB it is 23.0 ms vs main's 22.0 ms. Sentence-labelled classDiagram/erDiagram/stateDiagram-v2 relations wrap again, and so do pie title … and pie showData title …. All 82 corpus rows are correct at HEAD, and 36 rows improve against origin/main. The corpus file itself is the right artefact to have built.

But the round-2 false-positive class is narrowed, not closed. 37 of the 156 rows are worse at HEAD than at origin/main, every one of them prose that origin/main leaves alone and this branch rewrites as a broken diagram — which is T‑1840 itself. Under a Timeline opener, Decision: we ship on Friday. wraps, because the month alternative in timelinePeriod is not required to end the token, so any capitalised word of nine letters or fewer beginning Jan/Feb/…/Dec is a “period” (Decision, Marketing, Maybe, Novel, Novice, Separate, Junction, Octopus, Marching, Deciding). Under gantt, Note: we will ship after review completes. wraps, because \bafter\s{1,4}… is the ordinary English preposition and \b\d{1,4}[dwhms]\b matches “1990s”, “100m”, “30s”, “5h”. The new structural-segment guard cannot catch any of them: it tests the prefix the grammar claimed, and for exactly these patterns that prefix ends one character past the colon, so terseness is guaranteed by construction rather than earned.

Blocking, and it is the ticket's own bug class on its fourth attempt. The narrowing is real and the direction is right — this is a fix that is close, not a fix that is wrong.

Review findings

13 raised · 0 fixed · 13 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you paste text into Prism, it guesses whether you pasted a Mermaid diagram — the little text language for flowcharts and timelines — and if it thinks you did, it wraps the text in a code fence so it renders as a picture. Ticket T‑1840 is that the guess is too eager: an ordinary document beginning with the word “Graph” and containing an arrow in a sentence got silently rewritten into a broken diagram. Your prose vanished behind an error box.

Where this round started

The previous review found four problems. Two were serious: under a one-word Timeline heading any sentence containing a colon was being treated as diagram source, and one of the new patterns took 17 seconds on a 32 KB paste, on the thread that draws the screen.

What this commit does

It rewrites the patterns so each one looks for something prose does not carry — a timeline entry has to start with a year or a date, a Gantt task has to carry a date or a duration, a journey step has to carry a score. It also limits how far each pattern may search, which is what removed the 17 seconds. And it adds a table of 82 real pastes — a genuine diagram of every kind that must wrap, and prose of every shape that must not — so the next change is measured against all of them rather than against whichever bug was reported last.

What this review found

The slowness is genuinely fixed, and so are three other problems. But the patterns are still not quite anchored on what only diagrams have. The timeline pattern accepts any word that starts with a month abbreviation, so ordinary words like “Decision”, “Marketing” and “Maybe” count as dates. The Gantt pattern accepts the ordinary English word “after”, and reads “the 1990s” or “100m” as a duration. So under a document that opens with the single word Timeline, the sentence Decision: we ship on Friday. is still turned into a diagram — the original bug, one keyword over.

Architecture

ClipboardService.wrapMermaidIfNeeded(_:) is a three-gate pipeline called synchronously from DocumentFlowCoordinator.pasteFromClipboard() (a @MainActor method) via readContent()validateContent(_:): no existing fence, the opening line is a genuine declaration (declaredDiagramType), containsMermaidSyntax finds diagram syntax on some line, and there are 2+ non-empty lines.

What the four commits build

Commit 1 replaced the permissive MermaidTypeParser.parse (first-word-only, a labeller) with the strict declaration(in:) (a classifier). Commit 2 added the bare/qualified distinction: a bare keyword makes a strong-pattern match conditional on the line not reading as an English sentence. Commit 3 added an unconditional line-level grammar tier — which is what the last review blocked, because it gave the broadest pattern in the file veto power over every gate below it.

Commit abc1d5de, this round, does four things. It anchors each line-level pattern on a field shape (timelinePeriod, a gantt date/duration/after, a journey score, two identifiers around a relation operator). It bounds every quantifier ({1,64}, {0,200}) and drops (?m) in favour of per-line ^-anchoring. It makes the tier a corroboration signal rather than a veto, by sentence-testing the structural segment the grammar claimed. And it replaces the maxDeclarationTokenCount reject with explicit tail classification, so pie title … parses again.

Where it lands

The bounding worked exactly as intended and is measured flat. The anchoring is the part that is close but not there. Two of the field shapes are not distinctive of diagram source: a month prefix is not a month, and after is a preposition before it is a Gantt dependency keyword. And the structural-segment test — the mechanism that is supposed to catch precisely this — cannot fire for those two patterns, because the segment they claim stops before the prose starts.

Trade-offs the branch takes deliberately

Two genuine false negatives are documented and confirmed: a timeline entry whose period is free text (Roman Empire : …) and a gantt task with neither date nor duration are not recognised under a bare heading. Both are stated in the CHANGELOG and both reproduce identically on origin/main, so neither is a regression. That disclosure is exactly the house style; the 37 regressed rows are not disclosed, but they are bugs rather than residuals.

Differential result

I compiled ClipboardService.swift + MermaidTypeParser.swift + ClipboardError.swift out of tree at both revisions and ran one fixture set through wrapMermaidIfNeeded: the branch's own 82 corpus rows verbatim, plus 74 reviewer probes. 156 scored, 36 better at HEAD, 37 worse, 2 wrong on both (the disclosed residuals). All 82 corpus rows are correct at HEAD; none of the 37 is a corpus row, which is the point — the corpus is a real improvement in kind and still does not cover its own newest grammar.

Regression 1 — timelinePeriod, month prefix vs month

|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]{0,6}\.?(?:\s{1,4}\d{1,4})?

Nothing requires the alternation to end the token. [a-z]{0,6} then swallows the rest of any capitalised word up to nine letters: Decision, Marketing, Maybe, Novel, Novice, Separate, Junction, Octopus, Marching, Deciding. (The bound is load-bearing in the other direction too: Separation is 7 trailing letters and correctly fails — the class is defined by an arbitrary length cap, not by meaning.) Ten measured rows; Timeline\nDecision: we ship on Friday. is the shortest. A multi-paragraph article carrying one such line wraps whole.

Regression 2 — gantt metadata is ordinary English

^[^:\n]{1,120}:\s{0,8}[^\n]{0,200}?(?:\d{4}-\d{1,2}-\d{1,2}|\b\d{1,4}[dwhms]\b|\bafter\s{1,4}[A-Za-z_][\w-]{0,40})

All three branches fire on prose. \bafter\s{1,4}… is the preposition (Note: we will ship after review completes.). \b\d{1,4}[dwhms]\b matches decades and measurements — 1990s, 100m, 30s, 5h, 5m. The ISO-date branch matches a release date written in a sentence. Six measured rows. A real gantt task is :tag, date, duration — a comma-separated field list immediately after the colon, not a token found anywhere in the following 200 characters.

Why the new guard cannot see any of this

matchesLineLevelGrammar applies isSentenceShapedLineForStrongEvidence to String(line[line.startIndex..<matched.upperBound]). That is the right idea and the wrong measurement point. The Timeline pattern's match ends at \s{0,8}:\s{0,8}\S — one character past the colon. The Gantt pattern's ends at the date/duration token. The Journey pattern's ends at the second colon. In each case the claimed segment is short because the regex stops there, and it never ends in . / ? / ! / : / ), so isSentenceShapedLineForStrongEvidence returns false by construction. The only pattern in the tier whose match genuinely reaches the end of the structural half is Sequence's ^Note\s{1,8}(?:over|left of|right of)…: — and there the guard does work (verified: sequenceDiagram\nNote over the last decade the following has changed: is correctly refused). The guard is not weak; it is applied to a segment that only one of its patterns actually defines.

The bare/qualified asymmetry (both revisions)

declaration(ofType:modifiers:) returns isBare: false for any single tail token, and containsMermaidSyntax sets requiresSyntaxShapedStrongEvidence = isBare. So a two-word prose heading is exempt from the gate that a one-word one must pass: Block quotes, Journey home, Architecture diagram, Mindmap exercise, pie bananas all wrap with an ordinary arrow sentence beneath, on HEAD exactly as on origin/main. Not a regression — but this PR is what introduced the rule that a modifier disables the gate, and graph/flowchart is the only pair whose tail is validated against a token list. Extending flowchartDirectionTokens-style validation to the other keywords' modifiers (they are enumerable: showData, -v2, TD/LR…) would close it, and the corpus has no row that would notice either way.

Performance, re-measured

Arrow shape ("->" repeated), the round-1 blocker: HEAD is main, to within noise, at every size and every declared type. 32 KB — sequenceDiagram main 8.2 / HEAD 8.7 ms; timeline 7.9 / 8.1; gantt 8.0 / 8.4; classDiagram 7.8 / 8.1. 100 KB — 22.0 / 23.0, 22.4 / 22.2, 22.3 / 22.6, 21.8 / 22.2. Against 16,661 ms at 32 KB before. Closed.

A different space-free shape is not linear on either revision: a run of a ending in : costs 55 ms at 1 KB, 240 at 2 KB, 1,015 at 4 KB, 4,250 at 8 KB — a clean 4× per doubling, identical to the millisecond on main and HEAD, and past 25 s at 32 KB. The culprit is the pre-existing strong pattern \w+[^\S\n]*---[^\S\n]*\w+ (ClipboardService.swift:94, unchanged since before this branch): greedy \w+ over a space-free run, backtracking at every start position for a --- that is not there. Not a regression from this PR, and out of its scope — but it means the CHANGELOG's “the cost grows in step with the size of what you paste rather than with its square” and the decision log's “detection is linear in the size of the paste” are both false as stated, and detectionCostStaysLinearOnSpaceFreeLines asserts a property under a title it does not test: its only body shape is "->" repeated, which contains no \w at all and so can never reach the cliff. GrowthRatioGuard itself is sound (fastest-of-N, warm-ups, ratio ceiling 8 against a quadratic's 16); the fixture is the gap.

Scope, verified

MermaidTypeParser.swift has zero non-comment changes — parse's permissiveness is intact, and the doc comment naming the labeller/classifier distinction at the definition site is the most durable half of this whole fix. Its two other call sites consume already-fenced source. No production code outside ClipboardService calls wrapMermaidIfNeeded, containsMermaidSyntax or declaredDiagramType. The in-document ```mermaid fence path is not reachable from this diff.

Important changes — detailed

lineLevelGrammarPatterns: bounded and anchored — the quadratic is genuinely gone

prism/Services/ClipboardService.swift

Why it matters. Round-1 blocker 2, closed and measured. Every quantifier is now bounded ({1,64}, {0,200}, {0,8}), (?m) is dropped in favour of per-line ^-anchoring, and the sequence actor is spelled [^\s:]{1,64} — which is both faster and a truer statement of the grammar than \S+. A 32 KB space-free arrow line went 16,661 ms to 8.7 ms, against origin/main's own 8.2 ms; 100 KB is 23.0 ms vs main's 22.0 ms; flat under sequenceDiagram, timeline, gantt and classDiagram alike. wrapMermaidIfNeeded runs synchronously on @MainActor under a 10 MB clipboard cap, so this was a frozen window, and it is not one any more.

What to look at. ClipboardService.swift:278-306 (patterns); :254-276 (bounded sub-expressions)

Takeaway. An unbounded greedy quantifier immediately before a literal alternation is the standard catastrophic-backtracking shape. Bounding it to what the grammar actually admits fixes the cost and documents the intent in the same edit — and the bound belongs in a named sub-expression (classIdentifier, entityIdentifier, stateName), where the next reader sees it as a grammar statement rather than a performance hack.
Rationale. Stated in the commit message and in the patterns' own doc block: the round-2 tier's \S+ was greedy over a space-free run whose arrow characters are themselves non-space, so the engine tried every split point.

timelinePeriod: a month PREFIX is not a month — 10 prose rows regress

prism/Services/ClipboardService.swift

Why it matters. BLOCKER. `(?:Jan|Feb|…|Dec)[a-z]{0,6}\.?` never requires the alternation to end the token, so [a-z]{0,6} swallows the rest of any capitalised word of nine letters or fewer whose first three letters spell a month abbreviation. Measured wrapping at HEAD and correctly left alone at origin/main, all under a bare `Timeline` opener: `Decision: we ship on Friday.`, `Marketing: the campaign starts next week.`, `Maybe: …`, `Novel: …`, `Novice: …`, `Separate: …`, `Junction: …`, `Octopus: …`, `Marching: …`, `Deciding: …`. A multi-paragraph article carrying one such line wraps whole. This is the T-1840 false-positive class, narrower than round 2 but the same class.

What to look at. ClipboardService.swift:274-276 (timelinePeriod); :297 (the Timeline entry pattern)

Takeaway. An alternation of literals inside a token pattern needs a terminator, or it degrades from `is this token a month` to `does this token start with a month`. The bound that looks like the safety measure — [a-z]{0,6} — is what defines the false-positive class: `Separation` escapes only because it is one letter too long, which is not a distinction anyone intended to draw.
Rationale. The doc comment states the intent exactly right — 'a year, decade, era, ISO date, month … never free text, which is what made the round-2 pattern match any English sentence carrying a colon.' The intent is correct; the expression does not enforce it.

Gantt metadata: `after`, `1990s` and `100m` are English before they are Gantt

prism/Services/ClipboardService.swift

Why it matters. BLOCKER. All three branches of the metadata alternation fire on prose, and the lazy [^\n]{0,200}? lets them fire from anywhere in the 200 characters after the colon rather than from the field list. Measured wrapping at HEAD, left alone at origin/main: `gantt` + `Note: we will ship after review completes.` (the preposition), + `Result: he ran 100m in under ten seconds.` / `Summary: the outage lasted 30s and nobody noticed.` / `History: the chart was popularised in the 1990s.` (the duration branch reading measurements and decades), + `Schedule: the release is planned for 2026-03-01.` (a date in a sentence). Six measured rows.

What to look at. ClipboardService.swift:289-293

Takeaway. A field-shape anchor only works if the field's POSITION is anchored too. A gantt task line is `label :tag, date, duration` — a comma-separated list starting immediately after the colon. Searching 200 characters for any one of three tokens tests vocabulary, and this vocabulary is shared with English.
Rationale. Commit message: the round-2 pattern accepted 'a colon with a comma later', so the fix anchored on recognisable field shapes instead. Right diagnosis; the shapes chosen are not exclusive to the grammar.

matchesLineLevelGrammar: the structural-segment guard is inert for the patterns that need it

prism/Services/ClipboardService.swift

Why it matters. MAJOR, and the reason the two blockers above got through a mechanism added specifically to stop them. The guard sentence-tests line[..<match.upperBound] — the prefix the grammar claimed. But the Timeline pattern's match ends one character past the colon, the Gantt pattern's at the date/duration token, the Journey pattern's at the second colon. Those segments are short because the regex stops there, and none of them can end in . ? ! : ) — so isSentenceShapedLineForStrongEvidence returns false by construction, for every input. The one pattern whose match genuinely spans the structural half, Sequence's `Note over …:`, is the one where the guard demonstrably works (`sequenceDiagram\nNote over the last decade the following has changed:` is correctly refused).

What to look at. ClipboardService.swift:821-837; the segment definition at :830-832

Takeaway. Testing 'the part the grammar claimed' is only evidence when the grammar claims the whole structural half. Where a pattern stops at its first distinguishing token, the claimed segment's terseness is a property of the regex, not of the input — the guard reports success without having examined anything. Either extend each pattern to span its structural half, or test the segment the grammar did NOT claim (the free-text remainder) against the prose heuristic instead.
Rationale. Stated at length in the method's doc comment and endorsed as a design: 'for every genuine construct that segment is terse … a line whose structural half already reads as a sentence is not one of these shapes.' True of the Sequence and relation patterns, vacuous for Timeline, Gantt and Journey. (inferred — not stated by the author)

declaration(ofType:modifiers:): any single tail token disables the sentence-shape gate

prism/Services/ClipboardService.swift

Why it matters. MAJOR, pre-existing in effect but introduced as a rule by this PR. containsMermaidSyntax sets requiresSyntaxShapedStrongEvidence = isBare, and declaration(ofType:modifiers:) returns isBare: false for ANY one-token tail. So a two-word prose heading is exempt from the gate a one-word one must pass. Measured wrapping on BOTH revisions: `Block quotes`, `Journey home`, `Architecture diagram`, `Mindmap exercise`, `Timeline aggressive`, `pie bananas` — each followed by an ordinary arrow sentence. That is the reported repro (`Graph\nThe transition A --> B is discussed below.`) one word longer, and it is the one shape the 82-row corpus has no row for.

What to look at. ClipboardService.swift:699-713; the gate at :553 and :571; flowchartDirectionTokens at :58

Takeaway. `graph`/`flowchart` gets its tail validated against an enumerated token list; every other keyword accepts an arbitrary word as a qualifier. The modifier sets for the remaining types are just as enumerable (showData, TD/LR/BT/RL, -v2), so the asymmetry is an omission rather than a constraint — and 'deliberate, not something prose does by accident' is only true of a token prose cannot produce.
Rationale. Stated in the Declaration doc comment: 'specifying [a direction or modifier] is deliberate, not something prose does by accident.' Sound for a recognised token; unsound for an unrecognised one, which is what the code actually accepts. (inferred — not stated by the author)

ClipboardMermaidDetectionCorpusTests: the right artefact, one grammar short

prismTests/ClipboardMermaidDetectionCorpusTests.swift

Why it matters. This is the durable half of the round and it should survive whatever happens to the regexes: a single differential table, canonical mermaid-docs pastes for every recognised type against prose shapes for every keyword that is also an English word, with the explicit rule that adding a row is how a repro is reported and removing one has to be argued for in the CHANGELOG. Verified non-tautological — its prose rows genuinely fail at origin/main. Two gaps: none of the four newly-covered types (gitGraph, sankey, packet, kanban) has a PROSE row, and `kanban` reaches Kanban Board through a bare English-usable word, which is how `Kanban\n[Draft]` wraps; and the keyword-coverage test compares against a hand-copied 22-entry list, so a new type in MermaidTypeParser.diagramTypes will not fail it.

What to look at. ClipboardMermaidDetectionCorpusTests.swift:68-239 (table); :266-278 (coverage test); :293-305 (growth guard)

Takeaway. A corpus that only carries positive rows for a newly-covered type states that the type is DETECTED, not that it is detected CORRECTLY. Every grammar added to widen coverage needs its prose twin in the same commit — that pairing is the whole reason this file exists.
Rationale. Stated in the file header: three rounds each fixed the reported shape and moved the failure next door, because every round only added fixtures for its own repro.

Key decisions

Make the line-level tier a corroboration signal rather than a veto.

Round 2's tier returned true on any match, ahead of every other gate, which handed veto power to the broadest pattern in the file. This round keeps the tier first but conditions it on the structural segment being terse. Endorsed as a design — it is the correct answer to the round-2 blocker. The execution is where it falls down: for three of the twelve patterns the segment is short by construction, so the condition is vacuous (see the matchesLineLevelGrammar card).

Anchor every pattern on a field shape prose does not carry.

Timeline needs a year/date/era period, gantt a date or duration or after <id>, journey a numeric score, relations two identifiers around an operator. The right principle, and it works for the relation and journey-canonical shapes. It fails where the chosen field vocabulary overlaps English: a month prefix, the preposition after, and \d{1,4}[dwhms] reading “1990s” and “100m”.

Replace maxDeclarationTokenCount with explicit tail classification.

isInlineTitleTail accepts title <free text> (optionally after one modifier) and refuses a period-terminated tail, so mermaid's own pie title Pets adopted by volunteers parses again while “Timeline title case is preferred for headings.” does not. A title tail deliberately leaves the declaration bare, since prose can produce the word “title” by accident. Verified correct against the code and against both fixtures — this closes round-1 major 2 cleanly.

Extend the line-level tier to gitGraph, sankey, packet and kanban.

Recorded rationale (decision log, and the doc block at ClipboardService.swift:250-251): “each keyword is a non-word declaration, so the grammar cannot fire under prose.” That rationale is wrong for two of the four. MermaidTypeParser strips the -beta suffix, so a bare packet declares Packet Diagram; and kanban is an ordinary English-usable word — the corpus itself uses “Kanban” as a prose opener two rows below. Kanban\n[Draft] wraps at HEAD and does not at origin/main.

Keep the sentence-shape narrowing scoped to BARE declarations only.

A single tail token switches the gate off. This is what leaves Block quotes\nThe block A --> B is described below. wrapping on both revisions. Worth re-opening: graph/flowchart already validates its tail against flowchartDirectionTokens, and the other types' modifier sets are equally enumerable.

(inferred — not stated by the author.)
sentenceShapedLineWordThreshold = 5, unchanged.

Documented as the midpoint between the longest terse diagram line (Alice->>Bob: Are you there?, 4 tokens) and the shortest reported repro (6 tokens), and pinned by a boundary test at ClipboardServiceBareDeclarationTests.swift:99. Reasonable and genuinely mutation-guarded.

Decision log created for this feature.

specs/clipboard-mermaid-detection/decision_log.md is new and follows the project format correctly — one full Enhanced Nygard entry for the veto-to-corroboration reversal plus three quick-decision rows, all required fields, ISO dates, --- separators. The right call after three re-openings with three materially different strategies. Two corrections needed: the “detection is linear” claim at :96 is false as stated, and the Q3 rationale is wrong for packet and kanban.

smolspec.md was not updated and is still the linked spec of record.

specs/clipboard-render/requirements.md:47 points readers at specs/clipboard-mermaid-detection/smolspec.md, whose Detection Strategy still describes the superseded algorithm (“returns unchanged if MermaidTypeParser.parse() returns 'Diagram'”, gates joined with OR). Production requires a genuine declaration and AND-joins the gates, and explicitly rejects using parse() as the gate. tasks.md item 2 repeats it. Cheap to fix with a pointer to the decision log.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
blockerClipboardService.swift:274-276, :297 — timelinePeriod month alternative`(?:Jan|Feb|…|Dec)[a-z]{0,6}\.?` does not require the alternation to end the token, so any capitalised word of ≤9 letters starting with a month abbreviation is accepted as a timeline period. Measured wrapping at HEAD and correctly left alone at origin/main, all under a bare `Timeline` opener: Decision:, Marketing:, Maybe:, Novel:, Novice:, Separate:, Junction:, Octopus:, Marching:, Deciding: — 10 rows. A multi-paragraph article carrying one such line wraps whole. This is the T-1840 false-positive class, narrower than round 2 but the same class.Terminate the month alternative — require the period token to END there (a following `\b` is not enough since `[a-z]` is a word character; use `(?:Jan|…|Dec)(?:uary|ruary|ch|il|e|y|ust|tember|ober|ember)?\.?` or an explicit `(?![a-z])` after a 3-letter abbreviation). Add a prose row per rejected shape to the corpus — `Timeline\nDecision: we ship on Friday.` at minimum.
blockerClipboardService.swift:289-293 — gantt metadata alternationAll three branches match ordinary English, and the lazy `[^\n]{0,200}?` lets them match from anywhere in the 200 characters after the colon rather than from the field list. `\bafter\s{1,4}…` is the preposition; `\b\d{1,4}[dwhms]\b` reads `1990s`, `100m`, `30s`, `5h`, `5m`; the ISO branch reads a date written in a sentence. Measured wrapping at HEAD, left alone at origin/main, all under `gantt`: `Note: we will ship after review completes.`, `Result: he ran 100m in under ten seconds.`, `Summary: the outage lasted 30s and nobody noticed.`, `History: the chart was popularised in the 1990s.`, `Schedule: the release is planned for 2026-03-01.`, `Intro: the build takes 5m on a warm cache.` — 6 rows.Anchor the field list's POSITION, not just its vocabulary: a gantt task's metadata begins immediately after the colon and is comma-separated (`:\s*[A-Za-z_][\w-]*\s*,` or `:\s*(?:done|active|crit|milestone)\b`), with the date/duration required to be one of those comma-separated fields rather than a token found anywhere downstream. Add the six prose rows above to the corpus.
majorClipboardService.swift:821-837 — structural-segment guardThe guard sentence-tests `line[..<match.upperBound]`, but for the Timeline pattern the match ends one character past the colon, for Gantt at the date/duration token, for Journey at the second colon. Those segments are short because the regex stops there and can never end in `.`/`?`/`!`/`:`/`)`, so `isSentenceShapedLineForStrongEvidence` returns false for every input. The mechanism added this round to stop exactly the two blockers above cannot fire on either of them. It does work for Sequence's `Note over …:` pattern, which is the only one whose match spans its structural half (verified: `sequenceDiagram\nNote over the last decade the following has changed:` is correctly refused).Either make each pattern span its whole structural half so the segment is meaningful, or invert the test — apply the prose heuristic to the free-text REMAINDER's relationship to the line (e.g. require the structural segment to be a bounded fraction of the line, or require the line to carry no other sentence-shaped clause before the grammar match). A test that pins the guard by mutation — remove the `!isSentenceShapedLineForStrongEvidence` call and assert a row turns red for EACH of the twelve patterns — would have surfaced the vacuous three.
majorClipboardService.swift:305 + :699-713 — kanban and packet reach the tier through word-shaped keywordsThe doc block at :250-251 and decision-log Q3 both justify the four newly-covered types with 'each keyword is a non-word declaration, so the grammar cannot fire under prose'. `MermaidTypeParser` strips `-beta`, so bare `packet` declares Packet Diagram; and `kanban` is an ordinary word — the corpus uses 'Kanban' as a prose opener at :199-200. Measured: `Kanban\n[Draft]\nWe will finalise this next week.` and `Kanban\n[1]\nSee the reference above.` wrap at HEAD, not at origin/main. None of the four new grammars has a prose fixture in the corpus.Correct the stated rationale, and add a prose row for each of the four new grammars. The kanban item pattern in particular (`^\[…\]$`) needs something more than 'a bracketed token alone on a line' — a citation marker and a status tag have the same shape.
majorClipboardService.swift:553, :571, :699-713 — a modifier token disables the gate`requiresSyntaxShapedStrongEvidence = isBare`, and any single tail token yields `isBare: false`. So a two-word prose heading is exempt from the gate a one-word one must pass. Measured wrapping on BOTH revisions (not a regression, but the reported bug class still open): `Block quotes`, `Journey home`, `Architecture diagram`, `Mindmap exercise`, `Timeline aggressive`, `pie bananas`, each followed by an ordinary arrow sentence. `graph`/`flowchart` is the only pair whose tail is validated against a token list. The 82-row corpus has no row for this shape.Validate the modifier for every type the way `flowchartDirectionTokens` validates graph/flowchart — the sets are enumerable (`showData`, direction tokens, `-v2`). An unrecognised tail token should leave the declaration bare (still gated) rather than promote it to qualified. Add corpus rows for the two-word prose openers.
majorprismTests/ClipboardMermaidDetectionCorpusTests.swift:293-305 — growth guard tests one shape under a general title`detectionCostStaysLinearOnSpaceFreeLines` claims 'detection stays linear on a 100 KB space-free line under every declared type', but the only body it generates is `"->"` repeated, which contains no `\w` and so exercises only fast-failing anchored patterns. A different space-free line — a run of `a` ending in `:` — is 4x-per-doubling on BOTH origin/main and HEAD (1 KB 55 ms, 2 KB 240, 4 KB 1,015, 8 KB 4,250, >25 s at 32 KB), through the pre-existing strong pattern `\w+[^\S\n]*---[^\S\n]*\w+` at :94. `GrowthRatioGuard` itself is sound; the fixture is the gap. The cliff is pre-existing and out of this PR's scope, but the test's name asserts it is not there.Either narrow the test's name and doc to the shape it measures ('the round-2 sequence pattern is no longer quadratic'), or add the `\w`-run shape and let it fail, filing the pre-existing `---` pattern as its own ticket alongside T-1951 / T-2147.
minorCHANGELOG.md:25 and specs/clipboard-mermaid-detection/decision_log.md:96'the cost grows in step with the size of what you paste rather than with its square' and 'detection is linear in the size of the paste' are both false as stated — the `\w`-run shape above is quadratic on this branch as it was before it. Every OTHER claim in the CHANGELOG bullet checks out against measurement, including the two residuals it volunteers (a free-text timeline period and a date/duration-free gantt task are confirmed unwrapped on both revisions) and the `2024: a year of change.` false positive it discloses. The bullet does not mention the 37-row regression above, but those are bugs rather than residuals.Reword to the honest and still-impressive claim: the round-2 sequence-message pattern's quadratic blow-up is removed and its cost now matches origin/main at every size.
minorClipboardService.swift:560-564 — stale comment contradicts the new designThe inline comment in `containsMermaidSyntax` still reads 'Line-level grammar is checked first and, when it matches, is unconditional: a line whose overall SHAPE is a recognised diagram construct needs no corroboration from the sentence-shape gate below', citing the round-2 follow-up. That is precisely the behaviour this commit reverses, and `matchesLineLevelGrammar`'s own doc comment at :798 says so.Delete or rewrite the comment. A future reader reconciling the two will trust the one closest to the call site.
minorClipboardService.swift:188-253 — doc block attached to the wrong declarationThe 60-line explanatory block describing the line-level grammar tier runs straight into `/// A class or state name…` and therefore documents `classIdentifier` at :254. `lineLevelGrammarPatterns` at :278 — the thing the block is about — has no doc comment at all.Insert a blank line after :251 and move the block down to :277.
minorspecs/clipboard-mermaid-detection/smolspec.md and tasks.mdThe smolspec's Detection Strategy still describes the superseded algorithm ('returns unchanged if `MermaidTypeParser.parse()` returns "Diagram"', gates joined with OR) and `tasks.md` item 2 repeats it. `specs/clipboard-render/requirements.md:47` points readers here, so it is the spec of record for a reader arriving cold. Its Test Cases table is still accurate.Correct the strategy section, or add a one-line pointer to `decision_log.md` as the current description.
nitspecs/clipboard-mermaid-detection/decision_log.md:110-112 — Impact incompleteThe Impact section omits `declaration(ofType:modifiers:)` / `isInlineTitleTail` (the whole Q2 mechanism), `prism/Services/MermaidTypeParser.swift`, and two of the three test files.Extend the list.
nitprismTests/ClipboardServiceTests.swift:895 — test name contradicts its assertion`wrapMermaidIfNeededWrapsBarePieWithArrowSentence()` asserts that it does NOT wrap.Rename to `…DoesNotWrapBarePieWithArrowSentence`.
nitprismTests/ClipboardMermaidDetectionCorpusTests.swift:243-251 — hand-copied keyword list`declarations` claims to be 'every keyword MermaidTypeParser recognises' but is a static 22-entry copy, so a type added to `MermaidTypeParser.diagramTypes` will not fail the coverage test that exists to catch exactly that.Derive the list from `MermaidTypeParser.diagramTypes` (or assert the two are the same size) so a new type breaks the build until the corpus covers it.

Per-file diffs

Click to expand.

prism/Services/ClipboardService.swift Modified +444 / -28
diff --git a/prism/Services/ClipboardService.swift b/prism/Services/ClipboardService.swiftindex 9f1a0299..80fecd0c 100644--- a/prism/Services/ClipboardService.swift+++ b/prism/Services/ClipboardService.swift@@ -28,11 +28,49 @@ 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+    /// Diagram types whose grammar admits a `title <free text>` header on the+    /// declaration line itself (`pie title Pets adopted by volunteers`), rather+    /// than only as a `title` directive on a line of its own.+    ///+    /// The same four types `weakMermaidDirectives` pairs with the `title`+    /// keyword. Everything else must be the keyword plus at most one direction or+    /// modifier token (`flowchart LR`, `pie showData`) — never a sentence. See+    /// `declaration(ofType:modifiers:)`.+    private static let typesAdmittingInlineTitle: Set<String> = [+        "Pie Chart", "Gantt Chart", "User Journey", "Timeline",+    ]++    /// Recognised flowchart/graph direction tokens (case-insensitive).+    ///+    /// Unlike every other diagram type's keyword, bare `graph`/`flowchart` is+    /// not itself valid mermaid syntax -- real usage always pairs it with a+    /// direction. It is also, of every keyword in `MermaidTypeParser`, the one+    /// most likely to appear as an ordinary one-word prose opener (a heading, a+    /// lone noun). So `declaredDiagramType` requires one of these as the second+    /// token before treating `graph`/`flowchart` as a genuine declaration+    /// (T-1840): a bare `Graph` line no longer exempts a later strong-syntax+    /// match such as `-->` just because it happens to share a word with the+    /// diagram type.+    ///+    /// Matching strips a trailing `;` from the second token first: older+    /// mermaid docs/tutorials write `graph LR;`, and that is still a genuine+    /// direction-qualified header, not a bare one.+    private static let flowchartDirectionTokens: Set<String> = ["tb", "td", "bt", "rl", "lr"]++    /// A line counts as "sentence-shaped" -- prose rather than diagram syntax+    /// -- once it both ends in `.`/`?`/`!` and reaches this many+    /// whitespace-separated tokens.+    ///+    /// Used only to gate strong-pattern evidence under a BARE single-keyword+    /// declaration (see `containsMermaidSyntax`). Genuine terse diagram lines+    /// stay well under it: a sequence-diagram message such as+    /// `Alice->>Bob: Are you there?` is four tokens (the arrow is glued to the+    /// actor names, unlike prose). The reopened T-1840 repros -- "The rollout+    /// timeline shows Phase 1 --> Phase 2 --> Phase 3." (12 tokens), "Our+    /// journey --> success is inspiring." (6 tokens) -- clear it comfortably.+    /// Chosen as the midpoint of that gap (checked against every positive+    /// fixture in ClipboardServiceTests/MermaidTypeParserTests).+    private static let sentenceShapedLineWordThreshold = 5      // MARK: - Mermaid Detection Patterns @@ -147,6 +185,126 @@ enum ClipboardService {         (#"(?m)^\s*dateFormat\s+"#, ["Gantt Chart"]),     ] +    /// Line-level grammar patterns: structural diagram-line shapes that carry+    /// something prose does not — a glued arrow between two identifier tokens, a+    /// relationship operator between two identifiers, a date/duration field, a+    /// numeric journey score, a bare period token ahead of a colon.+    ///+    /// The tier exists because a diagram type's own grammar reserves part of such+    /// a line as FREE TEXT (everything after a sequence message's colon, a+    /// relationship's label, a timeline entry's event), and that free text is+    /// routinely a full sentence: `Alice->>Bob: Hello Bob, how are you today?` is+    /// a real diagram line that the strong tier's sentence-shape gate would+    /// otherwise discard. So the sentence-shape gate is applied to the+    /// STRUCTURAL SEGMENT of the line — the part this tier actually matched —+    /// rather than to the whole line (see `matchesLineLevelGrammar`); a match is+    /// evidence only when that segment is itself terse. This tier corroborates,+    /// it does not override: it can rescue a line the strong tier would reject,+    /// never accept one whose structural half already reads as a sentence.+    ///+    /// Every pattern is scoped by diagram type, like `weakMermaidDirectives`+    /// (only a text whose opening line declares the matching type gets the+    /// grammar), is anchored at `^` (lines are matched one at a time), and bounds+    /// every quantifier to what the grammar admits. The bounds are not+    /// micro-optimisation: an unbounded `\S+` before an alternation of arrow+    /// literals backtracks quadratically on a long space-free line, and this runs+    /// synchronously on the MainActor under a 10 MB clipboard cap (T-1840+    /// round-3 review; same defect class as T-1951 / T-2147). An actor, entity,+    /// class, or state name is an identifier, so `[^\s:]{1,64}` is both faster+    /// and more truthful than `\S+`.+    ///+    /// Breadth is the other half of the lesson. Round 2 wrote the Timeline entry+    /// as "any line with a colon" and the gantt task line as "any line with a+    /// colon and a later comma", on the grounds that those ARE the entry+    /// grammars. True of mermaid's parser, which only ever sees diagram source —+    /// and equally true of English, so under a bare `Timeline` opener every+    /// ordinary sentence carrying a colon ("Note: …", "starts at 10:30", a+    /// three-item list) auto-wrapped, which is T-1840 itself spelled with a+    /// different keyword. Each pattern is now anchored on the field shapes prose+    /// does not carry:+    ///+    /// - Sequence message (`Alice->>Bob: Hello Bob, how are you today?`): the+    ///   arrow glued between two identifier tokens ahead of the first colon.+    ///   Covers every sequence arrow: `->>`, `-->>`, `->`, `-->`, `-x`, `--x`,+    ///   `-)`, `--)`.+    /// - `participant`/`actor`/`activate`/`deactivate` declarations and+    ///   `Note over/left of/right of …:` annotations, for a paste whose sequence+    ///   diagram opens with those rather than with a message.+    /// - Class/state/ER relationships (`Animal <|-- Duck : implements a quack+    ///   behavior.`, `[*] --> Still : the machine has not started yet.`,+    ///   `CUSTOMER ||--o{ ORDER : a customer can place many orders.`): two+    ///   identifiers around a relationship operator, optionally labelled. A+    ///   descriptive, sentence-shaped edge label is ordinary in hand-written+    ///   class and ER diagrams, and without this grammar the bare-declaration+    ///   narrowing dropped all three (T-1840 round-3 review).+    /// - Gantt task line (`A task :a1, 2014-01-01, 30d`): a label, a colon, then+    ///   metadata fields including an ISO date, a `3d`/`2w` duration, or+    ///   `after <id>` — not merely a comma somewhere later.+    /// - User journey step (`Go to work: 5: Me`): a label, a colon, a numeric+    ///   score, a colon, then the actor list.+    /// - Timeline entry (`2002 : LinkedIn`): the period ahead of the colon must+    ///   be a bare year/date/era token, not free text.+    /// - Git graph commands, sankey `source,target,value` rows, packet+    ///   `0-15: "Field"` rows, and bracketed kanban items: bodies that carry no+    ///   pattern in any other tier, so those three types never auto-wrapped at+    ///   all. Each keyword is a non-word declaration (`gitGraph`, `sankey-beta`,+    ///   `packet-beta`, `kanban`), so the grammar cannot fire under prose.+    /// A class or state name as it appears at either end of a relationship.+    /// Bounded, like every quantifier in this tier.+    private static let classIdentifier = #"[A-Za-z_][\w.]{0,63}"#++    /// An ER entity name (hyphens are legal, dots are not).+    private static let entityIdentifier = #"[A-Za-z_][\w-]{0,63}"#++    /// A state name, or the `[*]` start/end marker.+    private static let stateName = #"(?:\[\*\]|[A-Za-z_][\w.]{0,63})"#++    /// Mermaid's class-diagram relationship operators, longest first (the regex+    /// alternation is leftmost-first, so `--` must not shadow `-->`).+    private static let classRelation =+        #"(?:<\|--|--\|>|\*--|--\*|o--|--o|<\|\.\.|\.\.\|>|<--|-->|<\.\.|\.\.>|--|\.\.)"#++    /// Mermaid's ER cardinality pair around a solid or dashed line+    /// (`||--o{`, `}o..o|`, ...).+    private static let erRelation = #"(?:\|o|\|\||\}o|\}\|)(?:--|\.\.)(?:o\||\|\||o\{|\|\{)"#++    /// A timeline period: a year, decade, era, ISO date, month (optionally with a+    /// year), or quarter — never free text, which is what made the round-2+    /// pattern match any English sentence carrying a colon.+    private static let timelinePeriod = #"(?:\d{1,4}(?:s|BC|BCE|AD|CE)?|\d{4}-\d{1,2}(?:-\d{1,2})?"#+        + #"|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]{0,6}\.?(?:\s{1,4}\d{1,4})?"#+        + #"|Q[1-4](?:\s{1,4}\d{2,4})?)"#++    private static let lineLevelGrammarPatterns: [(pattern: String, diagramTypes: Set<String>)] = [+        // Sequence: `Alice->>Bob: …` — arrow glued between two identifiers.+        (#"^[^\s:]{1,64}\s{0,8}(?:-->>|->>|-->|->|--x|-x|--\)|-\))\s{0,8}[^\s:]{1,64}\s{0,8}:"#, ["Sequence Diagram"]),+        (#"^(?:participant|actor|activate|deactivate)\s{1,8}\S{1,64}"#, ["Sequence Diagram"]),+        (#"^Note\s{1,8}(?:over|left of|right of)\s{1,8}[^:\n]{1,120}:"#, ["Sequence Diagram"]),+        // Class relationships: `Animal <|-- Duck`, `Duck ..> Flyer : uses`.+        (#"^\#(classIdentifier)\s{0,8}\#(classRelation)\s{0,8}\#(classIdentifier)\s{0,8}(?::|$)"#, ["Class Diagram"]),+        // ER relationships: `CUSTOMER ||--o{ ORDER : places`.+        (#"^\#(entityIdentifier)\s{0,8}\#(erRelation)\s{0,8}\#(entityIdentifier)\s{0,8}(?::|$)"#, ["ER Diagram"]),+        // State transitions: `[*] --> Still`, `S1 --> S2 : event`.+        (#"^\#(stateName)\s{0,8}-->\s{0,8}\#(stateName)\s{0,8}(?::|$)"#, ["State Diagram"]),+        // Gantt task metadata: a colon, then an ISO date, a duration, or `after <id>`.+        (+            #"^[^:\n]{1,120}:\s{0,8}[^\n]{0,200}?(?:\d{4}-\d{1,2}-\d{1,2}|\b\d{1,4}[dwhms]\b|\bafter\s{1,4}[A-Za-z_][\w-]{0,40})"#,+            ["Gantt Chart"]+        ),+        // Journey step: `Go to work: 5: Me`.+        (#"^[^:\n]{1,120}:\s{0,8}\d{1,2}\s{0,8}:"#, ["User Journey"]),+        // Timeline entry: the period ahead of the colon is a year/date/era token.+        (#"^\#(timelinePeriod)\s{0,8}:\s{0,8}\S"#, ["Timeline"]),+        // Git graph commands.+        (#"^(?:commit|branch|checkout|merge|cherry-pick|switch)(?:\s{1,8}[^\n]{0,200})?$"#, ["Git Graph"]),+        // Sankey CSV row: `source,target,value`.+        (#"^[^,\n]{1,80},[^,\n]{1,80},\s{0,4}\d{1,15}(?:\.\d{1,6})?\s{0,4}$"#, ["Sankey Diagram"]),+        // Packet field row: `0-15: "Source Port"`.+        (#"^\+?\d{1,5}(?:-\d{1,5})?\s{0,4}:\s{0,4}"[^"\n]{1,120}""#, ["Packet Diagram"]),+        // Kanban item: a bracketed task on a line of its own.+        (#"^\[[^\]\n]{1,120}\]\s{0,4}$"#, ["Kanban Board"]),+    ]+     /// Pre-compiled regex patterns for mermaid syntax detection.     /// Compiled once at load time to avoid repeated compilation on each paste operation.     private static let compiledStrongMermaidPatterns: [NSRegularExpression] = {@@ -157,14 +315,22 @@ enum ClipboardService {         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 {+    private static let compiledWeakDirectives = compileTypeScoped(weakMermaidDirectives)++    private static let compiledLineLevelGrammar = compileTypeScoped(lineLevelGrammarPatterns)++    /// Compiles a type-scoped pattern table once, dropping any pattern that fails+    /// to compile (the same tolerance the untyped tables use).+    private static func compileTypeScoped(+        _ entries: [(pattern: String, diagramTypes: Set<String>)]+    ) -> [(regex: NSRegularExpression, diagramTypes: Set<String>)] {+        entries.compactMap { entry in+            guard let regex = try? NSRegularExpression(pattern: entry.pattern) else {                 return nil             }-            return (regex: regex, diagramTypes: directive.diagramTypes)+            return (regex: regex, diagramTypes: entry.diagramTypes)         }-    }()+    }      /// Checks if clipboard contains text without reading content.     ///@@ -234,10 +400,21 @@ enum ClipboardService {     ///     /// This method uses a multi-signal approach to minimize false positives:     /// 1. Content must NOT already contain triple backticks (codefence)-    /// 2. First non-empty line must match a known mermaid diagram type+    /// 2. The opening line must be a genuine bare diagram declaration, not+    ///    merely start with a recognised keyword (see `declaredDiagramType`)     /// 3. Content must contain mermaid syntax patterns (arrows, nodes, etc.)     /// 4. Content must have 2+ non-empty lines (single-line content is likely prose)     ///+    /// Step 2 deliberately does NOT use `MermaidTypeParser.parse(text)` directly:+    /// that parser reads only the first WORD of the first line and is+    /// intentionally permissive (it exists to label source already known to be a+    /// diagram, e.g. an already-fenced mermaid block). Using it here let prose+    /// whose first line merely opened with a diagram keyword -- "Graph theory+    /// explains…", or even a bare "Graph" heading -- pass as though it were a+    /// declaration, which combined with a strong-syntax match anywhere else in+    /// the text (an unconditional pattern, see `containsMermaidSyntax`) was+    /// enough to wrap ordinary technical prose (T-1840).+    ///     /// - Parameter text: The text content to check.     /// - Returns: The original text wrapped in a mermaid codefence if detected,     ///           or the original text unchanged otherwise.@@ -247,10 +424,9 @@ enum ClipboardService {             return text         } -        // Check if MermaidTypeParser recognizes the content-        let diagramType = MermaidTypeParser.parse(text)-        guard diagramType != "Diagram" else {-            // Unrecognized type - don't wrap+        // Require the opening line to be a genuine bare declaration, not just a+        // first-word match.+        guard declaredDiagramType(in: text) != nil else {             return text         } @@ -294,12 +470,49 @@ 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 (`.`, `?`, `!`):+    /// Matching is done per line, in four tiers (T-1840). Line-level grammar+    /// (`lineLevelGrammarPatterns`: a sequence-diagram message, a class/ER/state+    /// relationship, a gantt task line, a journey step, a timeline entry, ...) is+    /// checked first, but it is NOT a veto: a pattern only matches a shape prose+    /// does not carry, and the match counts only when the structural segment it+    /// claimed is itself terse (see `matchesLineLevelGrammar`). That is what lets+    /// a genuine construct whose FREE TEXT reads as a sentence+    /// (`Alice->>Bob: Hello Bob, how are you today?`,+    /// `Animal <|-- Duck : implements a quack behavior.`) count as evidence,+    /// without handing veto power to the broadest pattern in the file — which is+    /// what round 2 did, re-opening the whole false-positive class for `Timeline`+    /// and `Gantt`. Only once a line carries no such grammar do the older three+    /// tiers apply. Strong patterns (arrows, relationship operators, pie's+    /// `"label": number`) match on every such line — a terse sequence-diagram+    /// message like `Alice->>Bob: Are you there?` ends in sentence punctuation+    /// but the arrow is unambiguous (and, being a genuine sequence message, is+    /// caught by the line-level-grammar tier before this one ever runs). The+    /// two weak tiers also occur in ordinary prose, so both are suppressed on+    /// a line ending in sentence punctuation (`.`, `?`, `!`):+    ///+    /// A fifth, narrower gate applies only when the text's opening line is a BARE+    /// single-keyword declaration -- a type name with no direction or modifier+    /// token, e.g. `Timeline` or `Journey` rather than `graph LR` or `pie+    /// showData` (T-1840, reopened). Every keyword other than `graph`/`flowchart`+    /// is valid mermaid syntax on its own, so `declaredDiagramType` accepts it as+    /// a genuine declaration -- but several of those keywords ("Timeline",+    /// "Journey", "Pie", "Mindmap", "Kanban", ...) are also ordinary English+    /// words, and a strong pattern like `-->` matches unconditionally anywhere+    /// later in the text regardless of surrounding prose. Under a bare+    /// declaration, a strong-pattern match on a line with no line-level grammar+    /// AND a sentence shape (ends in `.`/`?`/`!`/`:`/`)` AND has+    /// `sentenceShapedLineWordThreshold`-or-more words --+    /// `isSentenceShapedLineForStrongEvidence`; the `:`/`)` pair is a narrowing+    /// on top of `isSentenceShapedLine`'s `.`/`?`/`!`, added once it was clear+    /// prose ending in either could otherwise still reach this tier+    /// unchallenged) is rejected as evidence; a real diagram line has some+    /// OTHER strong or weak match, or a line-level grammar match, to fall back+    /// on. A declaration carrying a direction or modifier token is exempted --+    /// specifying one is deliberate, not something prose does by accident. A+    /// `title <free text>` tail is NOT such a token: prose can produce the word+    /// "title" by accident, so `pie title Pets adopted by volunteers` still has+    /// to find syntax-shaped evidence in its body (which its `"Dogs" : 386`+    /// value lines supply).     ///     /// - Shape patterns (bracket/paren node shapes, edge labels, state markers) get     ///   no exemption, because their prose doubles — a numbered citation@@ -335,17 +548,34 @@ enum ClipboardService {     /// - 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 declaredType = declaredDiagramType(in: text)+        let parsedDeclaration = declaration(in: text)+        let declaredType = parsedDeclaration?.type+        let requiresSyntaxShapedStrongEvidence = parsedDeclaration?.isBare == true         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)+            // Line-level grammar is checked first and, when it matches, is+            // unconditional: a line whose overall SHAPE is a recognised+            // diagram construct needs no corroboration from the sentence-shape+            // gate below, which exists only for an operator sitting loose in+            // an otherwise ordinary sentence (T-1840, round-2 follow-up).+            if matchesLineLevelGrammar(trimmed, nsRange: nsRange, declaredType: declaredType) {+                return true+            }             if compiledStrongMermaidPatterns.contains(where: { regex in                 regex.firstMatch(in: trimmed, range: nsRange) != nil             }) {-                return true+                if !requiresSyntaxShapedStrongEvidence || !isSentenceShapedLineForStrongEvidence(trimmed) {+                    return true+                }+                // Bare declaration, and this strong match landed on a+                // sentence-shaped line: fall through and let the weak tiers+                // (below) evaluate the same line on their own merits, rather+                // than accepting prose as evidence just because it happens to+                // contain an unambiguous operator (T-1840, reopened).             }             guard !endsInSentencePunctuation(trimmed) else {                 // The one exemption: a `?`/`!`-terminated directive whose keyword@@ -366,6 +596,20 @@ enum ClipboardService {         }     } +    /// A recognised diagram declaration: its type name, and whether it was written+    /// BARE — the keyword with no direction or modifier token.+    ///+    /// The bare/qualified distinction is what `containsMermaidSyntax` uses to+    /// decide whether a strong-pattern match needs corroboration from the line it+    /// landed on (T-1840, reopened): see `sentenceShapedLineWordThreshold`. A+    /// `title <free text>` tail does NOT make a declaration qualified — "title"+    /// is an ordinary English word, so prose can produce it by accident, whereas+    /// `LR` or `showData` is deliberate.+    private struct Declaration {+        let type: String+        let isBare: Bool+    }+     /// The mermaid diagram type the text *declares*, or nil when its opening line is     /// not a declaration.     ///@@ -381,26 +625,152 @@ enum ClipboardService {     /// 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.+    /// title from a hard-wrapped paragraph that merely starts with a keyword. It+    /// also gates the top-level `wrapMermaidIfNeeded` type check (T-1840).+    ///+    /// "Keyword on its own" is not sufficient for `graph`/`flowchart`: unlike+    /// every other recognised keyword, bare `graph`/`flowchart` is not itself+    /// valid mermaid syntax, and it is the keyword most likely to double as an+    /// ordinary one-word prose opener ("Graph" as a heading or a lone noun). For+    /// those two, a genuine declaration additionally requires a recognised+    /// direction token as the second token (see `flowchartDirectionTokens`).     ///     /// - 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.+    ///            a declaration of a recognised type.     private static func declaredDiagramType(in text: String) -> String? {+        declaration(in: text)?.type+    }++    /// Parses the text's opening line as a diagram declaration, or nil if it is+    /// not one. See `declaredDiagramType` for the shape a genuine declaration+    /// must have; this is the same parse, additionally reporting whether the+    /// declaration was bare (see `Declaration`).+    ///+    /// - Parameter text: The full text being checked.+    private static func declaration(in text: String) -> Declaration? {         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 {+            let tokens = trimmed.split(whereSeparator: \.isWhitespace).map(String.init)+            let type = MermaidTypeParser.parse(trimmed)+            guard type != "Diagram" else {                 return nil             }-            let type = MermaidTypeParser.parse(trimmed)-            return type == "Diagram" ? nil : type+            let modifiers = Array(tokens.dropFirst())+            if type == "Flowchart" {+                // A trailing `;` on the direction token (`graph LR;`) is a+                // genuine, if dated, mermaid style and must not be read as an+                // unrecognised modifier.+                guard modifiers.count == 1,+                      flowchartDirectionTokens.contains(+                          modifiers[0].trimmingCharacters(in: CharacterSet(charactersIn: ";")).lowercased()+                      ) else {+                    return nil+                }+                return Declaration(type: type, isBare: false)+            }+            return declaration(ofType: type, modifiers: modifiers)         }         return nil     } +    /// Classifies the tokens that follow a recognised diagram keyword on the+    /// declaration line.+    ///+    /// Accepted tails: nothing (`gantt`); a single direction or modifier token+    /// (`pie showData`); or a `title <free text>` header, optionally after one+    /// modifier (`pie title Pets adopted by volunteers`,+    /// `pie showData title Key elements in Product X`). The title form is the one+    /// in mermaid's own pie documentation and the most likely real-world pie+    /// paste, and it was rejected outright while the whole wrap gate inherited+    /// `maxDeclarationTokenCount` (T-1840 round-3 review).+    ///+    /// The title tail is deliberately NOT treated as a qualifier: prose can open+    /// "Timeline title case is preferred…" by accident in a way it cannot open+    /// "flowchart LR", so such a declaration still has to find syntax-shaped+    /// evidence in the body (see `Declaration.isBare`).+    ///+    /// - Parameters:+    ///   - type: The declared diagram type.+    ///   - modifiers: The declaration line's tokens after the type keyword.+    private static func declaration(ofType type: String, modifiers: [String]) -> Declaration? {+        if modifiers.isEmpty {+            return Declaration(type: type, isBare: true)+        }+        if isInlineTitleTail(modifiers, for: type) {+            return Declaration(type: type, isBare: true)+        }+        if modifiers.count == 1 {+            return Declaration(type: type, isBare: false)+        }+        if modifiers.count > 1, isInlineTitleTail(Array(modifiers.dropFirst()), for: type) {+            return Declaration(type: type, isBare: false)+        }+        return nil+    }++    /// Whether `tokens` is a `title <free text>` header for a diagram type whose+    /// grammar admits one on the declaration line.+    ///+    /// A period-terminated tail is refused, the same rule the directive tier+    /// applies to a `title` line: `pie title Are we on track?` is idiomatic, while+    /// "Timeline title case is preferred for headings." is a sentence that happens+    /// to have a diagram keyword and the word "title" in its first two positions.+    private static func isInlineTitleTail(_ tokens: [String], for type: String) -> Bool {+        guard tokens.count >= 2,+              tokens[0].lowercased() == "title",+              tokens[tokens.count - 1].hasSuffix(".") == false else {+            return false+        }+        return typesAdmittingInlineTitle.contains(type)+    }++    /// Checks whether a (trimmed, non-empty) line both ends in sentence-terminating+    /// punctuation AND has enough words to read as an actual sentence rather than a+    /// terse diagram line (see `sentenceShapedLineWordThreshold`).+    ///+    /// - Parameter line: A trimmed, non-empty line.+    private static func isSentenceShapedLine(_ line: String) -> Bool {+        guard endsInSentencePunctuation(line) else {+            return false+        }+        let wordCount = line.split(whereSeparator: \.isWhitespace).count+        return wordCount >= sentenceShapedLineWordThreshold+    }++    /// Like `isSentenceShapedLine`, but for the STRONG-pattern corroboration+    /// gate only: additionally treats a trailing `:` or `)` as sentence-+    /// terminating.+    ///+    /// Those two are common sentence endings ("The operation A --> B works+    /// well:", "...works well (see above)") that `endsInSentencePunctuation`+    /// does not otherwise recognise, so a bare-declaration prose sentence+    /// ending in either one used to reach the strong pattern unchallenged+    /// (T-1840, round-2 follow-up). The extension is scoped to this one call+    /// site rather than folded into `endsInSentencePunctuation` itself,+    /// because by the time `containsMermaidSyntax` reaches it,+    /// `matchesLineLevelGrammar` has already returned false for the line --+    /// so it cannot suppress a genuine gantt/journey/timeline colon-bearing+    /// entry or a parenthesised sequence-diagram label, all of which are+    /// exempted upstream, unconditionally, by their own line-level grammar+    /// instead.+    ///+    /// - Parameter line: A trimmed, non-empty line with no matching+    ///   line-level grammar.+    private static func isSentenceShapedLineForStrongEvidence(_ line: String) -> Bool {+        if isSentenceShapedLine(line) {+            return true+        }+        guard let last = line.last, last == ":" || last == ")" else {+            return false+        }+        let wordCount = line.split(whereSeparator: \.isWhitespace).count+        return wordCount >= sentenceShapedLineWordThreshold+    }+     /// Checks whether a (trimmed, non-empty) line ends in any sentence-terminating     /// punctuation.     ///@@ -419,4 +789,50 @@ enum ClipboardService {         }         return last == "." || last == "?" || last == "!"     }++    /// Checks whether a (trimmed, non-empty) line matches a recognised+    /// line-level diagram grammar for the text's declared diagram type, AND that+    /// the part of the line the grammar matched is itself terse rather than+    /// sentence-shaped. See `lineLevelGrammarPatterns`.+    ///+    /// The second half is what makes this tier a corroboration signal rather than+    /// an override (T-1840 round-3 review). Round 2 returned true on any match,+    /// ahead of every other gate, which made the broadest pattern in the file the+    /// one with veto power. The sentence-shape test cannot simply be applied to+    /// the whole line — the whole point of the tier is that a diagram grammar+    /// reserves part of the line as free text, and that free text is routinely a+    /// sentence (`Alice->>Bob: Hello Bob, how are you today?`). It is applied+    /// instead to the STRUCTURAL SEGMENT: the prefix of the line up to the end of+    /// the grammar match, which is the part the grammar itself claims. For every+    /// genuine construct that segment is terse (`Alice->>Bob:`, `2002 :`,+    /// `Animal <|-- Duck :`, `A task :a1, 2014-01-01`); a line whose structural+    /// half already reads as a sentence is not one of these shapes, whatever the+    /// regex says.+    ///+    /// - Parameters:+    ///   - line: A trimmed, non-empty line.+    ///   - nsRange: `line`'s full range, pre-computed by the caller (which+    ///     already needs it for the strong/weak pattern checks).+    ///   - declaredType: The text's declared diagram type, or nil if its+    ///     opening line is not a recognised declaration.+    /// - Returns: True if `line` matches a line-level grammar pattern whose+    ///   diagram types include `declaredType`, with a non-sentence-shaped+    ///   structural segment.+    private static func matchesLineLevelGrammar(_ line: String, nsRange: NSRange, declaredType: String?) -> Bool {+        guard let declaredType else {+            return false+        }+        for entry in compiledLineLevelGrammar where entry.diagramTypes.contains(declaredType) {+            guard let match = entry.regex.firstMatch(in: line, range: nsRange),+                  let matched = Range(match.range, in: line) else {+                continue+            }+            let structuralSegment = String(line[line.startIndex..<matched.upperBound])+                .trimmingCharacters(in: .whitespaces)+            if !isSentenceShapedLineForStrongEvidence(structuralSegment) {+                return true+            }+        }+        return false+    } }
prism/Services/MermaidTypeParser.swift Doc only +10 / -0
diff --git a/prism/Services/MermaidTypeParser.swift b/prism/Services/MermaidTypeParser.swiftindex 39703388..cee28827 100644--- a/prism/Services/MermaidTypeParser.swift+++ b/prism/Services/MermaidTypeParser.swift@@ -51,6 +51,16 @@ nonisolated enum MermaidTypeParser {      /// Parses the diagram type from mermaid source.     ///+    /// This reads only the first WORD of the first non-comment line and is+    /// deliberately permissive: it exists to label source already known to be a+    /// diagram (e.g. an already-fenced mermaid block), not to decide whether+    /// arbitrary text IS mermaid source. A caller that needs the latter — such+    /// as clipboard auto-wrap detection — must not use this method's result+    /// alone as evidence of a genuine declaration, because many recognised+    /// keywords ("graph", "pie", "journey", "timeline", "kanban", ...) are also+    /// ordinary English words; see `ClipboardService.declaredDiagramType` for+    /// the stricter check that gates auto-wrap (T-1840).+    ///     /// - Parameter source: The raw mermaid diagram source code.     /// - Returns: A human-readable diagram type name, or "Diagram" if the type cannot be determined.     static func parse(_ source: String) -> String {
prismTests/ClipboardMermaidDetectionCorpusTests.swift Added +306 / -0
diff --git a/prismTests/ClipboardMermaidDetectionCorpusTests.swift b/prismTests/ClipboardMermaidDetectionCorpusTests.swiftnew file mode 100644index 00000000..e6848d8a--- /dev/null+++ b/prismTests/ClipboardMermaidDetectionCorpusTests.swift@@ -0,0 +1,306 @@+//+//  ClipboardMermaidDetectionCorpusTests.swift+//  prismTests+//+//  T-1840 (round-3 review): the differential fixture table IS the spec.+//+//  T-1840 has been fixed and re-opened three times, and each round fixed the+//  reported shape while moving the failure next door: the round-1 declaration+//  gate lost sentence-labelled class/ER/state diagrams and `pie title …`; the+//  round-2 line-level grammar tier re-opened the false-positive class wholesale+//  for `Timeline` and `Gantt` ("any line with a colon" is also a description of+//  English). None of that was visible from the suite, because every round only+//  added fixtures for its own repro.+//+//  The review that caught it compiled ClipboardService out of tree at both+//  origin/main and HEAD and diffed the verdicts over one fixture set. This file+//  is that fixture set, kept in the suite: every recognised diagram type as a+//  real paste (canonical one-liners from mermaid's own documentation) that MUST+//  wrap, and, for every keyword that is also an ordinary English word, the prose+//  shapes that MUST NOT — colon-bearing sentences, times of day, ingredient and+//  phase lists, arrow-bearing technical prose, and a 100 KB article.+//+//  A change to detection is measured against the whole table, not against the+//  newest repro. Adding a row is how a new repro is reported; removing one is a+//  deliberate narrowing that has to be argued for in the CHANGELOG.+//++import Foundation+import Testing+@testable import prism++/// One clipboard paste and the verdict auto-wrap must reach for it.+struct MermaidDetectionFixture: Sendable, CustomTestStringConvertible {+    let label: String+    let text: String+    let shouldWrap: Bool++    var testDescription: String { label }+}++/// The corpus: real diagrams (must wrap) and prose (must not).+enum MermaidDetectionCorpus {+    private static func diagram(_ label: String, _ text: String) -> MermaidDetectionFixture {+        MermaidDetectionFixture(label: label, text: text, shouldWrap: true)+    }++    private static func prose(_ label: String, _ text: String) -> MermaidDetectionFixture {+        MermaidDetectionFixture(label: label, text: text, shouldWrap: false)+    }++    /// A 100 KB prose article whose every paragraph carries the colon shapes the+    /// round-2 Timeline/Gantt grammar treated as diagram syntax.+    static let longArticle: String = String(+        repeating: """+            The release timeline slipped for three reasons: staffing, scope, and a late dependency bump. \+            Note: the meeting starts at 10:30 and runs late. Ingredients: flour, sugar, eggs. \+            We shipped the following: alpha, beta, and GA.++            """,+        count: 700+    )++    // MARK: - Real diagrams (must wrap)++    /// Canonical one-liners from mermaid's documentation, one per recognised+    /// diagram type, plus the sentence-labelled and title-carrying variants each+    /// earlier round of T-1840 broke.+    static let diagrams: [MermaidDetectionFixture] = [+        diagram("flowchart TD canonical", "flowchart TD\n    Start --> Stop"),+        diagram("graph LR; dated form", "graph LR;\nA-->B"),+        diagram("graph TD + sentence label", "Graph TD\nThe transition A --> B is shown below."),+        diagram(+            "sequenceDiagram canonical",+            "sequenceDiagram\n    Alice->>John: Hello John, how are you?\n    John-->>Alice: Great!"+        ),+        diagram(+            "sequenceDiagram dialogue messages",+            "sequenceDiagram\nAlice->>Bob: Hello Bob, how are you today?\n"+                + "Bob-->>Alice: I am doing quite well today, thank you."+        ),+        diagram(+            "sequenceDiagram participant/Note only",+            "sequenceDiagram\nparticipant Alice\nparticipant Bob\n"+                + "Note over Alice,Bob: They exchange greetings warmly and continue their long conversation."+        ),+        diagram("classDiagram terse relation", "classDiagram\n    Animal <|-- Duck"),+        diagram("classDiagram sentence label", "classDiagram\nAnimal <|-- Duck : implements a quack behavior."),+        diagram("classDiagram member block", "classDiagram\nclass Animal{\n+int age\n+String gender\n}"),+        diagram("stateDiagram-v2 canonical", "stateDiagram-v2\n    [*] --> Still\n    Still --> [*]"),+        diagram("stateDiagram-v2 sentence label", "stateDiagram-v2\n[*] --> Still : the machine has not started yet."),+        diagram("stateDiagram-v2 event label", "stateDiagram-v2\nS1 --> S2 : the user confirms the order."),+        diagram("erDiagram canonical", "erDiagram\n    CUSTOMER ||--o{ ORDER : places"),+        diagram(+            "erDiagram sentence label",+            "erDiagram\nCUSTOMER ||--o{ ORDER : a customer can place many orders."+        ),+        diagram(+            "gantt canonical",+            "gantt\n    title A Gantt Diagram\n    dateFormat YYYY-MM-DD\n    section Section\n"+                + "    A task :a1, 2014-01-01, 30d"+        ),+        diagram("gantt bare + task line", "gantt\nTask name :a1, 2026-01-01, 3d"),+        diagram("gantt title on declaration", "gantt title Project schedule\nDesign phase :done, des1, 2026-01-01, 5d"),+        diagram("gantt title/dateFormat only", "gantt\ntitle A Gantt Diagram\ndateFormat YYYY-MM-DD"),+        diagram("gantt punctuated title", "gantt\ntitle Are we on track?\nsection Can we ship?"),+        diagram(+            "pie title one-liner",+            "pie title Pets adopted by volunteers\n    \"Dogs\" : 386\n    \"Cats\" : 85\n    \"Rats\" : 15"+        ),+        diagram(+            "pie showData title one-liner",+            "pie showData title Key elements in Product X\n    \"Calcium\" : 42.96\n    \"Potassium\" : 50.05"+        ),+        diagram("pie bare + value line", "pie\n\"Dogs\": 386"),+        diagram("pie + title directive", "pie\ntitle My Pie\n\"A\": 30\n\"B\": 70"),+        diagram(+            "journey canonical",+            "journey\n    title My working day\n    section Go to work\n      Make tea: 5: Me\n      Go upstairs: 3: Me"+        ),+        diagram("journey bare + step", "journey\nGo to work: 5: Me"),+        diagram("journey title/section only", "journey\ntitle My working day\nsection Go to work"),+        diagram("journey punctuated title", "journey\ntitle Are we on track?\nsection Is this done?"),+        diagram("gitGraph canonical", "gitGraph\n   commit\n   branch develop\n   checkout develop\n   commit"),+        diagram(+            "C4Context canonical",+            "C4Context\n  title System Context diagram for Internet Banking System\n"+                + "  Person(customerA, \"Banking Customer A\", \"A customer of the bank.\")"+        ),+        diagram("mindmap canonical", "mindmap\n  root((mermaid))\n    Origins\n      Long history"),+        diagram(+            "timeline canonical",+            "timeline\n    title History of Social Media Platform\n    2002 : LinkedIn\n    2004 : Facebook : Google"+        ),+        diagram("timeline bare + entry", "timeline\n2024 : Launch product."),+        diagram("timeline title + entry", "timeline\ntitle History\n2000 : Event A"),+        diagram(+            "quadrantChart canonical",+            "quadrantChart\n    title Reach and engagement of campaigns\n    x-axis Low Reach --> High Reach\n"+                + "    quadrant-1 We should expand"+        ),+        diagram(+            "xychart-beta canonical",+            "xychart-beta\n    title \"Sales Revenue\"\n    x-axis [jan, feb, mar]\n    bar [5000, 6000, 7500]"+        ),+        diagram("block-beta canonical", "block-beta\n  columns 1\n  db((\"DB\"))\n  blockArrowId<[\"&nbsp;\"]>(down)"),+        diagram(+            "requirementDiagram canonical",+            "requirementDiagram\n    requirement test_req {\n    id: 1\n    text: the test text.\n    risk: high\n    }"+        ),+        diagram(+            "sankey-beta canonical",+            "sankey-beta\n\nAgricultural waste,Bio-conversion,124.729\nBio-conversion,Liquid,0.597"+        ),+        diagram("packet-beta canonical", "packet-beta\n0-15: \"Source Port\"\n16-31: \"Destination Port\""),+        diagram(+            "architecture-beta canonical",+            "architecture-beta\n    group api(cloud)[API]\n    service db(database)[Database] in api"+        ),+        diagram("kanban canonical", "kanban\n  Todo\n    [Create Documentation]\n  Done\n    [Prepare tutorial]"),+        diagram("zenuml canonical", "zenuml\n    title Order Service\n    @Actor Alice\n    Alice->Bob: Hello Bob"),+        diagram("pie showData + arrow sentence", "pie showData\nThe recipe transforms A --> B in two steps."),+        diagram("comment line then gantt", "%% a comment\ngantt\ntitle Are we on track?"),+        diagram("bare timeline + terse arrow", "Timeline\nA --> B end."),+    ]++    // MARK: - Prose (must not wrap)++    /// Every keyword that is also an ordinary English word, paired with the prose+    /// shapes reported across the three re-openings of T-1840.+    static let prose: [MermaidDetectionFixture] = [+        prose("Graph + arrow sentence", "Graph\nThe transition A --> B is discussed below."),+        prose("Flowchart + arrow sentence", "Flowchart\nThe state A --> B transition is discussed below."),+        prose("bare graph + terse arrow", "graph\nA --> B"),+        prose("graph + unrecognised modifier", "graph mermaid\nA --> B"),+        prose("Timeline + arrow sentence", "Timeline\nThe rollout timeline shows Phase 1 --> Phase 2 --> Phase 3."),+        prose("Timeline + Note: prefix", "Timeline\nNote: this document explains why our release timeline slipped."),+        prose("Timeline + time of day", "Timeline\nThe meeting starts at 10:30 and runs late."),+        prose("Timeline + ingredient list", "Timeline\nIngredients: flour, sugar, eggs"),+        prose("Timeline + phase list", "Timeline\nThe project has three phases: design, build, ship."),+        prose("Timeline + 100 KB article", "Timeline\n" + longArticle),+        prose("Gantt + Summary: list", "gantt\nSummary: we reviewed budget, schedule, and risk."),+        prose("Gantt + following: list", "Gantt\nWe shipped the following: alpha, beta, and GA."),+        prose(+            "Gantt + history sentence",+            "Gantt\nHenry Gantt invented the chart in 1910: it was, and is, widely used."+        ),+        prose("Gantt + arrow sentence", "Gantt\nThe project schedule moves Phase A --> Phase B next week."),+        prose("Journey + arrow sentence", "Journey\nOur journey --> success is inspiring."),+        prose("Journey + hours colon", "Journey\nMy journey home: 2 hours: not fun at all"),+        prose("Journey + itinerary colon", "Journey\nDay 1: we drove, walked, and rested."),+        prose("Journey + Note: prefix", "Journey\nNote: this document explains the customer journey."),+        prose("Pie + colon-terminated sentence", "Pie\nThe operation A --> B works well:"),+        prose("Pie + paren-terminated sentence", "Pie\nThe operation A --> B works well (see above)"),+        prose("Pie + ingredient list", "Pie\nIngredients: flour, sugar, eggs"),+        prose("pie + recipe sentence", "pie\nThe recipe transforms A --> B in two steps."),+        prose("Mindmap + arrow sentence", "Mindmap\nOur brainstorm links Idea A --> Idea B directly."),+        prose("Block + arrow sentence", "Block\nThe block A --> B is described below."),+        prose("Block + time of day", "Block\nThe meeting starts at 10:30 and runs late."),+        prose("Kanban + arrow sentence", "Kanban\nThe kanban board moves Card A --> Card B daily."),+        prose("Kanban + Note: prefix", "Kanban\nNote: this board is stale."),+        prose(+            "Architecture + arrow sentence",+            "Architecture\nThe architecture routes Service A --> Service B over gRPC."+        ),+        prose("bare Timeline, single line", "Timeline"),+        prose(+            "no declaration at all",+            "The transition A --> B is discussed below.\nAnother line: with a colon."+        ),+        prose("bare timeline + sentence", "Timeline\nA --> B is done."),+        prose(+            "keyword paragraph + section sentence",+            "Timeline for the migration project remains aggressive.\n"+                + "section 3 of the report covers rollout details and risk."+        ),+        prose(+            "keyword paragraph + title sentence",+            "Architecture reviews happen quarterly at our company.\n"+                + "title case is preferred for all section headings in docs."+        ),+        prose(+            "bracketed citations",+            "Block quotes are useful[1] for citing other authors.\n"+                + "This technique appears frequently in academic writing[2]."+        ),+        prose(+            "journey parenthetical prose",+            "Journey mapping(a UX technique) helps teams understand users.\n"+                + "It is often used early in the design process."+        ),+        prose("Timeline mapping + section?", "Timeline mapping is useful\nsection 3 covers this?"),+        prose(+            "sequenceDiagram + arrow sentence",+            "sequenceDiagram\nThe system diagram shows A --> B in more detail than expected."+        ),+    ]++    static let all: [MermaidDetectionFixture] = diagrams + prose+}++struct ClipboardMermaidDetectionCorpusTests {++    /// Every keyword `MermaidTypeParser` recognises, in the form a paste declares+    /// it. Used both to prove the corpus covers each type and to sweep the+    /// growth guard across all of them.+    static let declarations = [+        "flowchart TD", "graph LR", "sequenceDiagram", "classDiagram", "stateDiagram-v2", "erDiagram",+        "gantt", "pie", "journey", "gitGraph", "C4Context", "mindmap", "timeline", "quadrantChart",+        "xychart-beta", "block-beta", "requirementDiagram", "sankey-beta", "packet-beta",+        "architecture-beta", "kanban", "zenuml",+    ]++    @Test("Corpus: every fixture reaches the verdict the table declares", arguments: MermaidDetectionCorpus.all)+    func corpusFixtureReachesDeclaredVerdict(_ fixture: MermaidDetectionFixture) {+        let result = ClipboardService.wrapMermaidIfNeeded(fixture.text)+        if fixture.shouldWrap {+            #expect(+                result == "```mermaid\n\(fixture.text)\n```",+                "\(fixture.label): a real diagram must be wrapped in a mermaid codefence"+            )+        } else {+            #expect(result == fixture.text, "\(fixture.label): prose must be left exactly as pasted")+        }+    }++    @Test("Corpus covers every recognised diagram keyword")+    func corpusCoversEveryRecognisedKeyword() {+        // A type whose canonical paste is in no row is a type the table does not+        // defend: the class/ER/state regressions survived three rounds precisely+        // because their only fixtures were terse ones.+        for declaration in Self.declarations {+            let keyword = declaration.split(separator: " ")[0].lowercased()+            let covered = MermaidDetectionCorpus.diagrams.contains { fixture in+                fixture.text.lowercased().hasPrefix(keyword)+            }+            #expect(covered, "No wrapping fixture opens with `\(keyword)`")+        }+    }++    /// Detection must stay LINEAR in the length of the paste, under every declared+    /// type, on the input shape that provokes regex backtracking: a long run with+    /// no spaces and plenty of arrow-like substrings.+    ///+    /// The round-2 sequence-message pattern `^\S+\s*(?:->>|-->|…)\s*\S+\s*:` was+    /// quadratic on exactly that shape — 12 ms at 1 KB, 1.0 s at 8 KB, 16.7 s at+    /// 32 KB, a clean 4x per doubling — and `wrapMermaidIfNeeded` runs+    /// synchronously on the MainActor from `pasteFromClipboard()` under a 10 MB+    /// clipboard cap, so that is a frozen window rather than a slow function+    /// (T-1840 round-3 review; same defect class as T-1951 / T-2147). The+    /// unbounded quantifiers are now bounded to what each grammar admits, and the+    /// measured sweep is flat: 8 KB 1.7 ms, 32 KB 7.0 ms, 100 KB 21 ms under+    /// every one of these declarations.+    @Test("GrowthRatioGuard: detection stays linear on a 100 KB space-free line under every declared type")+    func detectionCostStaysLinearOnSpaceFreeLines() {+        for declaration in Self.declarations {+            GrowthRatioGuard.expectLinearGrowth(+                shape: "space-free arrow run under `\(declaration)`",+                baseCount: 25 * 1024,+                multiplier: 4+            ) { byteCount in+                let body = String(repeating: "->", count: byteCount / 2) + "b"+                _ = ClipboardService.wrapMermaidIfNeeded("\(declaration)\n\(body)")+            }+        }+    }+}
prismTests/ClipboardServiceBareDeclarationTests.swift Added +235 / -0
diff --git a/prismTests/ClipboardServiceBareDeclarationTests.swift b/prismTests/ClipboardServiceBareDeclarationTests.swiftnew file mode 100644index 00000000..274ad288--- /dev/null+++ b/prismTests/ClipboardServiceBareDeclarationTests.swift@@ -0,0 +1,235 @@+//+//  ClipboardServiceBareDeclarationTests.swift+//  prismTests+//+//  T-1840 (reopened, local review): bare-keyword-plus-strong-operator prose is+//  reachable through every OTHER single-word diagram keyword, not just+//  graph/flowchart.+//+//  declaredDiagramType's direction-token requirement is specific to+//  graph/flowchart (the one keyword pair that is not valid mermaid syntax on+//  its own). Every other recognised keyword -- "pie", "timeline", "journey",+//  "mindmap", "gantt", "kanban", "architecture", "block", ... -- is complete,+//  valid mermaid syntax bare, and several of those are also ordinary English+//  words. Before this fix, a bare "Timeline"/"Journey"/"Pie"/"Gantt"/"Mindmap"+//  opener plus an unrelated sentence containing a strong operator (`-->`)+//  still auto-wrapped, the identical shape as the original repro with a+//  different opening word. The fix requires a bare (single-token)+//  declaration's strong-pattern evidence to come from a line that does not+//  read as an ordinary sentence (see `sentenceShapedLineWordThreshold`); a+//  declaration carrying a direction/modifier token is unaffected, since+//  specifying one is deliberate, not something prose does by accident.+//+//  Split into its own file (extension on `ClipboardServiceTests`) to keep+//  `ClipboardServiceTests` under the project's type_body_length limit.+//++import Testing+import Foundation+@testable import prism++extension ClipboardServiceTests {++    @Test("wrapMermaidIfNeeded does NOT wrap technical prose opening with a bare 'Timeline' line")+    func wrapMermaidIfNeededDoesNotWrapBareTimelineWithArrowSentence() {+        let input = "Timeline\nThe rollout timeline shows Phase 1 --> Phase 2 --> Phase 3."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'Timeline' opener does not exempt a sentence-shaped strong pattern")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap technical prose opening with a bare 'Journey' line")+    func wrapMermaidIfNeededDoesNotWrapBareJourneyWithArrowSentence() {+        let input = "Journey\nOur journey --> success is inspiring."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'Journey' opener does not exempt a sentence-shaped strong pattern")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap technical prose opening with a bare 'Gantt' line")+    func wrapMermaidIfNeededDoesNotWrapBareGanttWithArrowSentence() {+        let input = "Gantt\nThe project schedule moves Phase A --> Phase B next week."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'Gantt' opener does not exempt a sentence-shaped strong pattern")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap technical prose opening with a bare 'Mindmap' line")+    func wrapMermaidIfNeededDoesNotWrapBareMindmapWithArrowSentence() {+        let input = "Mindmap\nOur brainstorm links Idea A --> Idea B directly."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'Mindmap' opener does not exempt a sentence-shaped strong pattern")+    }++    // Positive controls: the same five diagram types, using genuine terse+    // diagram syntax instead of a sentence, must still be detected -- the fix+    // narrows what counts as evidence, it does not remove strong-pattern+    // detection for bare declarations altogether.++    @Test("wrapMermaidIfNeeded still wraps a bare 'pie' declaration with a terse arrow line")+    func wrapMermaidIfNeededStillWrapsBarePieWithTerseArrowLine() {+        let input = "pie\nA --> B"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A terse strong-pattern line is still valid evidence under a bare declaration")+    }++    @Test("wrapMermaidIfNeeded still wraps a bare 'timeline' declaration with a terse arrow line")+    func wrapMermaidIfNeededStillWrapsBareTimelineWithTerseArrowLine() {+        let input = "timeline\nPhase 1 --> Phase 2"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A terse strong-pattern line is still valid evidence under a bare declaration")+    }++    @Test("containsMermaidSyntax rejects a strong-pattern match on a sentence-shaped line under a bare declaration")+    func containsMermaidSyntaxRejectsStrongMatchOnSentenceUnderBareDeclaration() {+        #expect(ClipboardService.containsMermaidSyntax("Timeline\nThe rollout timeline shows Phase 1 --> Phase 2 --> Phase 3.") == false)+        #expect(ClipboardService.containsMermaidSyntax("Journey\nOur journey --> success is inspiring.") == false)+    }++    @Test("containsMermaidSyntax still accepts a strong-pattern match on a terse line under a bare declaration")+    func containsMermaidSyntaxAcceptsStrongMatchOnTerseLineUnderBareDeclaration() {+        #expect(ClipboardService.containsMermaidSyntax("timeline\nPhase 1 --> Phase 2") == true)+        #expect(ClipboardService.containsMermaidSyntax("journey\nA --> B") == true)+    }++    @Test("containsMermaidSyntax still accepts a strong-pattern match on a sentence-shaped line under a qualified declaration")+    func containsMermaidSyntaxAcceptsStrongMatchOnSentenceUnderQualifiedDeclaration() {+        // "pie showData" carries a modifier token, so it is not a BARE+        // declaration and the sentence-shape gate does not apply to it.+        #expect(ClipboardService.containsMermaidSyntax("pie showData\nThe recipe transforms A --> B in two steps.") == true)+    }++    @Test("isSentenceShapedLine boundary: word count exactly at the threshold rejects the strong match")+    func sentenceShapeThresholdBoundary() {+        // Both lines carry the same strong pattern (`-->`) and end in a period;+        // only their word count differs. Pins sentenceShapedLineWordThreshold+        // (5) as a mutation guard: raising or lowering it would flip one of+        // these two cases.+        //+        // 4 tokens ("A", "-->", "B", "end.") -- one short of the threshold,+        // so the strong match is still accepted as evidence.+        #expect(ClipboardService.containsMermaidSyntax("Timeline\nA --> B end.") == true)+        // 5 tokens ("A", "-->", "B", "is", "done.") -- exactly at the+        // threshold, so the line reads as a sentence and the strong match is+        // rejected.+        #expect(ClipboardService.containsMermaidSyntax("Timeline\nA --> B is done.") == false)+    }++    // MARK: - T-1840 (round-2 review follow-up): Line-level grammar++    // The sentence-shape gate above rejects a strong-pattern match that lands+    // on a sentence-shaped line under a bare declaration -- but that gate was+    // never meant to (and must not) apply to a line whose overall SHAPE+    // already IS the diagram's own grammar, where "the text after a colon" is+    // free-form BY DEFINITION: a sequence-diagram message, a gantt task line,+    // a journey step, a timeline entry. A genuine diagram of exactly that+    // shape -- ordinary dialogue in a sequence diagram being the most common+    // -- was silently rejected in its entirety before this fix, because every+    // line failed the sentence-shape corroboration and there was nothing else+    // to fall back on.++    @Test("wrapMermaidIfNeeded wraps a bare sequenceDiagram whose messages read as ordinary dialogue")+    func wrapMermaidIfNeededWrapsSequenceDiagramWithDialogueMessages() {+        // The exact round-2 review repro: every message line is >= 5 tokens+        // and ends in `?`/`.`, so before this fix NEITHER line supplied+        // strong-pattern evidence and the whole paste stayed unwrapped.+        let input = "sequenceDiagram\nAlice->>Bob: Hello Bob, how are you today?\nBob-->>Alice: I am doing quite well today, thank you."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A genuine sequence message is diagram grammar regardless of how conversational its text reads")+    }++    @Test("wrapMermaidIfNeeded wraps a bare sequenceDiagram with only participant/Note lines, no messages")+    func wrapMermaidIfNeededWrapsSequenceDiagramParticipantAndNoteLines() {+        let input = """+            sequenceDiagram+            participant Alice+            participant Bob+            Note over Alice,Bob: They exchange greetings warmly and continue their long conversation.+            """+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(+            result == "```mermaid\n\(input)\n```",+            "participant/Note declarations are sequence-diagram grammar on their own, independent of any message line"+        )+    }++    @Test("wrapMermaidIfNeeded does NOT wrap bare sequenceDiagram prose whose arrow is not shaped as a message")+    func wrapMermaidIfNeededDoesNotWrapSequenceDiagramProseWithoutMessageShape() {+        // The arrow here is not glued between two identifier tokens ahead of a+        // colon, so it is ordinary prose, not a sequence message -- the+        // sentence-shape gate must still apply to it exactly as before.+        let input = "sequenceDiagram\nThe system diagram shows A --> B in more detail than expected."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(+            result == input,+            "An arrow embedded in an ordinary sentence is not sequence-diagram grammar just because the text opens with a bare declaration"+        )+    }++    @Test("wrapMermaidIfNeeded wraps a bare gantt declaration with a genuine task line")+    func wrapMermaidIfNeededWrapsGanttTaskLine() {+        // "Task name :a1, 2026-01-01, 3d" carries no arrow or bracket, so+        // before this fix it matched no pattern at all under any declaration.+        let input = "gantt\nTask name :a1, 2026-01-01, 3d"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A task line's `:field, field, field` shape is gantt grammar regardless of the task name's wording")+    }++    @Test("wrapMermaidIfNeeded wraps a bare journey declaration with a genuine step line")+    func wrapMermaidIfNeededWrapsJourneyStepLine() {+        let input = "journey\nGo to work: 5: Me"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A step's `:score:actor` shape is journey grammar")+    }++    @Test("wrapMermaidIfNeeded wraps a bare timeline declaration with a genuine period entry")+    func wrapMermaidIfNeededWrapsTimelineEntryLine() {+        // No title/section/dateFormat directive and no arrow -- before this+        // fix a bare period-entry-only timeline matched no pattern at all.+        let input = "timeline\n2024 : Launch product."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A period-colon-event entry is timeline grammar even though the event text ends in a period")+    }++    @Test("wrapMermaidIfNeeded still wraps a bare pie declaration with a genuine value line")+    func wrapMermaidIfNeededWrapsPieValueLine() {+        // Positive control: pie's `"label": number` shape was already+        // unconditional strong-pattern evidence (it does not end in sentence+        // punctuation), so this fixture pins the pre-existing behaviour+        // rather than something this round changed.+        let input = "pie\n\"Dogs\": 386"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap bare-declaration prose whose sentence ends in a colon")+    func wrapMermaidIfNeededDoesNotWrapColonTerminatedProseSentence() {+        // Round-2 review repro: the original fix only recognised `.`/`?`/`!`+        // as sentence-terminating, so a bare-declaration sentence ending in+        // `:` still reached the strong pattern unchallenged.+        let input = "Pie\nThe operation A --> B works well:"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(+            result == input,+            "A colon-terminated sentence is still a sentence, not pie grammar -- 'Pie' carries no line-level grammar of its own"+        )+    }++    @Test("wrapMermaidIfNeeded does NOT wrap bare-declaration prose whose sentence ends in a closing paren")+    func wrapMermaidIfNeededDoesNotWrapParenTerminatedProseSentence() {+        let input = "Pie\nThe operation A --> B works well (see above)"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A parenthetical-terminated sentence is still a sentence")+    }++    @Test("containsMermaidSyntax accepts line-level grammar even on a sentence-shaped line")+    func containsMermaidSyntaxAcceptsLineLevelGrammarRegardlessOfSentenceShape() {+        #expect(ClipboardService.containsMermaidSyntax("sequenceDiagram\nAlice->>Bob: Hello Bob, how are you today?") == true)+        #expect(ClipboardService.containsMermaidSyntax("gantt\nTask name :a1, 2026-01-01, 3d") == true)+        #expect(ClipboardService.containsMermaidSyntax("journey\nGo to work: 5: Me") == true)+        #expect(ClipboardService.containsMermaidSyntax("timeline\n2024 : Launch product.") == true)+    }++    @Test("containsMermaidSyntax rejects a colon- or paren-terminated sentence with no line-level grammar")+    func containsMermaidSyntaxRejectsColonAndParenTerminatedSentences() {+        #expect(ClipboardService.containsMermaidSyntax("Pie\nThe operation A --> B works well:") == false)+        #expect(ClipboardService.containsMermaidSyntax("Pie\nThe operation A --> B works well (see above)") == false)+    }+}
prismTests/ClipboardServiceTests.swift Modified +81 / -0
diff --git a/prismTests/ClipboardServiceTests.swift b/prismTests/ClipboardServiceTests.swiftindex 954dcc26..e1943e56 100644--- a/prismTests/ClipboardServiceTests.swift+++ b/prismTests/ClipboardServiceTests.swift@@ -862,4 +862,85 @@ struct ClipboardServiceTests {         #expect(ClipboardService.containsMermaidSyntax("pie showData\ntitle Are we on track?") == true)         #expect(ClipboardService.containsMermaidSyntax("%% a comment\ngantt\ntitle Are we on track?") == true)     }++    // MARK: - T-1840 (reopened): Bare-keyword-plus-strong-operator prose++    // PR #364 gated the two WEAK syntax tiers on sentence punctuation, but strong+    // patterns (arrows, relationship operators) match unconditionally, and the+    // diagramType gate in wrapMermaidIfNeeded still called MermaidTypeParser.parse+    // directly, which reads only the first WORD of the first line -- so a bare+    // one-word opener like "Graph" satisfied it exactly as well as a real+    // declaration. Technical prose whose second line happens to carry an arrow+    // operator (a common shape when discussing code or graph theory) reached the+    // strong-pattern tier unconditionally and was wrapped. The fix requires a+    // genuine bare declaration line -- for "graph"/"flowchart" specifically, a+    // recognised direction token, since bare "graph" alone is not valid mermaid+    // syntax and is indistinguishable from a one-word prose opener.++    @Test("wrapMermaidIfNeeded does NOT wrap technical prose opening with a bare 'Graph' line")+    func wrapMermaidIfNeededDoesNotWrapBareGraphWithArrowSentence() {+        // The exact reopening repro (Codex, 2026-08-15): a bare keyword line+        // followed by a sentence containing a strong operator (`-->`).+        let input = "Graph\nThe transition A --> B is discussed below."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'Graph' opener is not a genuine declaration and must not exempt an unconditional strong pattern")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap technical prose opening with a bare 'Flowchart' line")+    func wrapMermaidIfNeededDoesNotWrapBareFlowchartWithArrowSentence() {+        let input = "Flowchart\nThe state A --> B transition is discussed below."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'Flowchart' opener with no direction is not a genuine declaration")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap a bare 'pie' declaration followed by an unrelated arrow sentence")+    func wrapMermaidIfNeededWrapsBarePieWithArrowSentence() {+        // Unlike graph/flowchart, bare "pie" IS complete, valid mermaid syntax --+        // no direction or second token is required -- so declaredDiagramType+        // accepts it as a genuine declaration on its own. But "pie" is also an+        // ordinary English word, and this is the same reopened T-1840 shape as+        // "Timeline"/"Journey" below: a sentence-shaped line (ends in `.` with+        // several words) cannot supply strong-pattern evidence for a BARE+        // declaration, so this must stay unwrapped (T-1840, reopened).+        let input = "pie\nThe recipe transforms A --> B in two steps."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A bare 'pie' opener followed by an arrow sentence is not evidence of a real pie chart")+    }++    @Test("wrapMermaidIfNeeded still wraps a genuine flowchart declaration with a direction token")+    func wrapMermaidIfNeededStillWrapsFlowchartWithDirection() {+        // A real bare declaration (keyword + recognised direction token) must+        // still be detected -- this is the positive control for the fix above.+        let input = "Graph TD\nThe transition A --> B is shown below."+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A genuine direction-qualified declaration must still wrap")+    }++    @Test("wrapMermaidIfNeeded does NOT wrap a graph declaration with an unrecognised second token")+    func wrapMermaidIfNeededDoesNotWrapGraphWithUnrecognisedModifier() {+        let input = "graph mermaid\nA --> B"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == input, "A second token that is not a recognised direction is not a genuine declaration")+    }++    @Test("wrapMermaidIfNeeded requires a direction token for a bare graph/flowchart declaration")+    func wrapMermaidIfNeededRequiresDirectionForFlowchart() {+        // containsMermaidSyntax matches the strong `-->` pattern unconditionally+        // regardless of any declaration -- it is wrapMermaidIfNeeded's own+        // declaration gate that must reject the bare (no-direction) form while+        // still accepting the direction-qualified one.+        #expect(ClipboardService.wrapMermaidIfNeeded("graph\nA --> B") == "graph\nA --> B")+        #expect(ClipboardService.wrapMermaidIfNeeded("flowchart\nA --> B") == "flowchart\nA --> B")+        #expect(ClipboardService.wrapMermaidIfNeeded("graph LR\nA --> B") == "```mermaid\ngraph LR\nA --> B\n```")+        #expect(ClipboardService.wrapMermaidIfNeeded("flowchart TD\nA --> B") == "```mermaid\nflowchart TD\nA --> B\n```")+    }++    @Test("wrapMermaidIfNeeded accepts a semicolon-terminated direction token")+    func wrapMermaidIfNeededAcceptsSemicolonTerminatedDirection() {+        // Older mermaid docs/tutorials write `graph LR;` -- the trailing+        // semicolon must not defeat the direction-token match.+        let input = "graph LR;\nA --> B"+        let result = ClipboardService.wrapMermaidIfNeeded(input)+        #expect(result == "```mermaid\n\(input)\n```", "A semicolon-terminated direction token is still a genuine declaration")+    } }
specs/clipboard-mermaid-detection/decision_log.md Added +115 / -0
diff --git a/specs/clipboard-mermaid-detection/decision_log.md b/specs/clipboard-mermaid-detection/decision_log.mdnew file mode 100644index 00000000..507d6fee--- /dev/null+++ b/specs/clipboard-mermaid-detection/decision_log.md@@ -0,0 +1,115 @@+# Decision Log: Clipboard Mermaid Detection++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-23 | The differential fixture table lives in the suite as `ClipboardMermaidDetectionCorpusTests`, not in a reviewer's scratch directory | Three re-openings each fixed the newest repro and broke an adjacent one; only a whole-table check makes that visible |+| Q2 | 2026-08-23 | `title <free text>` on the declaration line is accepted for the four types that admit a `title` directive, and does NOT count as a qualifier | `pie title Pets adopted by volunteers` is mermaid's own documented form, but "title" is an ordinary English word, so the body still has to supply syntax-shaped evidence |+| Q3 | 2026-08-23 | Line-level grammar added for git graph, sankey, packet and kanban bodies | Those types never auto-wrapped at all; their keywords are not English words, so the grammar cannot fire under prose |++---++## Decision 1: Line-level diagram grammar corroborates evidence, it does not override the prose gate++**Date**: 2026-08-23+**Status**: accepted++### Context++T-1840 ("clipboard prose can be rewritten as a Mermaid diagram") was fixed and+re-opened three times. The detection pipeline has four tiers, and the tension is+always the same: a diagram type's grammar reserves part of a line as FREE TEXT —+everything after a sequence message's colon, a relationship's label, a timeline+entry's event — and that free text routinely reads as an English sentence. A+prose heuristic that rejects sentence-shaped lines therefore drops real diagrams+(`Alice->>Bob: Hello Bob, how are you today?`), while one that exempts them wraps+prose.++Round 2 resolved that by adding a fourth tier of type-scoped "line-level grammar"+patterns, consulted FIRST and returning early on a match. Two of those patterns+carried no structure at all: a timeline entry was `^\S[^:\n]*:[^:\n]*\S` ("any+line with a colon") and a gantt task was `^[^:\n]+:[^\n]*,` ("a colon with a comma+later"). Because the tier ran ahead of every other gate, a bare `Timeline` or+`Gantt` opener plus any colon-bearing sentence — "Note: …", "The meeting starts+at 10:30 and runs late.", "Ingredients: flour, sugar, eggs" — auto-wrapped: the+original bug, spelled with a different keyword. The same tier also introduced a+quadratic sequence-message pattern (`^\S+\s*(?:->>|-->|…)\s*\S+\s*:`) that took+16.7 s on a 32 KB space-free line, synchronously on the MainActor under a 10 MB+clipboard cap.++### Decision++The line-level tier stays, but it is a corroboration signal rather than an+override. Each pattern is anchored on the field shapes prose does not carry (a+date/era period, an ISO date or duration, a numeric score, a relationship+operator between two identifiers, an arrow glued between two identifier tokens),+bounds every quantifier to what the grammar admits, and a match counts only when+the STRUCTURAL SEGMENT it claimed — the prefix of the line through the end of the+match — is not itself sentence-shaped. Type-specific relation grammar was added+for class, ER and state diagrams so the bare-declaration narrowing does not drop+them.++### Rationale++The sentence-shape test cannot be applied to the whole line: that is precisely+the case the tier exists to rescue, and doing so re-breaks the sequence-dialogue+fix that round 2 correctly landed. Applied to the structural segment, it tests+the half of the line the grammar actually claims and leaves the reserved free+text alone — `Alice->>Bob:`, `2002 :`, `Animal <|-- Duck :`,+`A task :a1, 2014-01-01` are all terse, while a line whose structural half reads+as a sentence is not one of these shapes whatever the regex says.++Bounding the quantifiers is not an optimisation. An unbounded `\S+` ahead of an+alternation of arrow literals is the standard catastrophic-backtracking shape,+and this code runs on the thread that draws the screen. `[^\s:]{1,64}` is also+more truthful: a sequence actor is an identifier, not an arbitrary run of+non-space.++### Alternatives Considered++- **Require ≥2 grammar lines for the colon-based types (timeline/gantt/journey)**:+  repetition as corroboration - Rejected: a minimal two-line paste+  (`gantt` + one task line, `timeline` + one entry) is a real diagram and one of+  the fixtures the earlier rounds were asked to support; the overlap with prose+  is removed at the source by anchoring the pattern instead.+- **Scope the bare-declaration narrowing to keywords that are ordinary English+  words** (leaving `classDiagram`, `erDiagram`, `stateDiagram-v2` unnarrowed) -+  Rejected as the primary mechanism: it re-introduces a hand-maintained list of+  "English-looking" keywords, and the relation grammar is both narrower and+  useful in its own right. The observation still holds and is why the added+  grammar for the non-word keywords (git graph, sankey, packet, kanban) is safe.+- **Drop the line-level tier and accept the sequence-dialogue false negative** -+  Rejected: a sequence diagram whose messages read as dialogue is the single most+  ordinary sequence diagram there is.++### Consequences++**Positive:**++- The Timeline/Gantt false-positive class is closed at the pattern level, not by+  another gate stacked on top.+- Sentence-labelled class, ER and state diagrams wrap again, and git graph,+  sankey, packet and kanban bodies wrap for the first time.+- Detection is linear in the size of the paste: the 32 KB space-free line went+  from 16.7 s to ~7 ms, pinned by `GrowthRatioGuard` under every declared type.++**Negative:**++- Genuine constructs outside the anchored shapes need a second line: a timeline+  entry whose period is free text ("Roman Empire : …") and a gantt task carrying+  neither a date nor a duration are not recognised on their own under a bare+  heading.+- A sentence that is genuinely shaped like a diagram entry ("2024: a year of+  change." under a bare `Timeline`) still wraps — at that point the two are the+  same text.+- The pattern table is longer and more specific, so a mermaid grammar change is+  more likely to need a matching change here.++### Impact++`prism/Services/ClipboardService.swift` (pattern table, `matchesLineLevelGrammar`,+`declaration(in:)`), `prismTests/ClipboardMermaidDetectionCorpusTests.swift`+(the fixture table and the growth guard).++---
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..2ce18999 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Pasting text that merely *mentions* a diagram no longer rewrites it as a broken diagram, and pasting a real diagram still works (T-1840, reported and re-opened three times). Prism wraps a paste in a mermaid codefence when it looks like diagram source, and that judgement was wrong in both directions. It now asks three things. First, the opening line has to be a real declaration: the keyword on its own, or with a direction (`graph LR`, a trailing `;` allowed), a modifier (`pie showData`), or a title (`pie title Pets adopted by volunteers` — the form in mermaid’s own documentation, which a previous round of this fix had stopped accepting). A paragraph that merely begins with a word that is also a keyword — "Graph", "Timeline", "Journey", "Pie", "Gantt", "Mindmap", "Block", "Kanban", "Architecture" — is not a declaration. Second, under a bare keyword with nothing after it, an operator such as `-->` found further down only counts as evidence if it lands on a line that reads as diagram source rather than as an English sentence (five or more words ending in `.`, `?`, `!`, `:` or `)` reads as a sentence). Third, a line whose whole shape is a diagram construct counts even when its text reads as a sentence, because these grammars reserve part of the line as free text: a sequence message (`Alice->>Bob: Hello Bob, how are you today?`), a class, ER or state relationship (`Animal <|-- Duck : implements a quack behavior.`, `CUSTOMER ||--o{ ORDER : a customer can place many orders.`, `[*] --> Still : the machine has not started yet.` — all three of which a previous round had stopped wrapping), a gantt task carrying a date or a duration, a journey step carrying a score, a timeline entry whose period is a year or date, and the bodies of git graphs, sankey diagrams, packet diagrams and kanban boards, which never auto-wrapped at all before. That third test is deliberately anchored on those field shapes. An earlier attempt at it accepted "any line containing a colon" as a timeline entry and "a colon with a comma later" as a gantt task, which meant that under a one-word `Timeline` or `Gantt` heading every ordinary sentence carrying a colon — "Note: …", "The meeting starts at 10:30 and runs late.", "Ingredients: flour, sugar, eggs", "Summary: we reviewed budget, schedule, and risk." — was rewritten as a diagram, which is the original bug spelled with a different keyword. Detection is also no longer slow on awkward input: a long line without spaces made one of these tests take 17 seconds on a 32 KB paste, on the same thread that draws the screen; the same paste now takes 7 milliseconds, and the cost grows in step with the size of what you paste rather than with its square. What is guaranteed, and now checked as one table rather than one repro at a time: a canonical diagram of every recognised type wraps, and prose does not, for every keyword that is also an English word. What is not: the narrowing is real, so a few genuine diagrams still need a second line to be recognised. A timeline entry whose period is free text ("Roman Empire : …") rather than a year or date, and a gantt task carrying neither a date nor a duration, are not recognised on their own under a bare heading — add the diagram’s `title` or `section` line, or another entry, and they are. In the other direction, a sentence that is genuinely shaped like a diagram entry ("2024: a year of change." under a bare `Timeline` heading) still wraps: at that point the two are the same text. - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.

Things to double-check

The 37 regressed rows are all one class, and it is close to closed.

Two regexes carry all of them, and both need the same kind of edit — terminate the token, anchor the field position. Neither the pipeline shape, the tier ordering, nor the corroboration design is implicated. This is a fix that is close, not a fix that is wrong: the third-round narrowing did work, and the remaining hole is one keyword class narrower again than round 2's “any line with a colon”.

Add the reviewer probes to the corpus regardless of how the regexes are fixed.

The corpus is the right artefact and its own header says adding a row is how a repro is reported. The 37 rows measured here, the two-word prose openers, and a prose twin for each of the four newly-covered grammars belong in it — that is what makes the next round measurable rather than another repro-shaped patch. The harness that produced them is at /tmp/t1840rev2 (fixtures.swift, run_main/run_head).

The pre-existing `---` cliff deserves its own ticket.

\w+[^\S\n]*---[^\S\n]*\w+ at ClipboardService.swift:94 is 4×-per-doubling on a space-free \w run and reachable from the same synchronous @MainActor paste path under a 10 MB cap: 4.2 s at 8 KB, past 25 s at 32 KB, identical on both revisions. Out of scope for this PR — but the branch's own CHANGELOG, decision log and growth test all currently assert it is gone.

Everything the first review asked for on performance was delivered.

Worth saying plainly: 16,661 ms → 8.7 ms at 32 KB, matching origin/main to within noise at every size and every declared type, with a growth guard added to keep it there. The bounding approach (naming classIdentifier, entityIdentifier, stateName as grammar statements rather than inlining {1,64}) is better than what was suggested.