From 3eeb4976fca8c0ec353037bc716addbe3b3fddc4 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 5 Jul 2026 23:41:44 +0300 Subject: [PATCH] fix(gitmost-bridge): neutralize col-0 block triggers in transcript lines + lock text-not-HTML (#378 review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review found two issues: - [regressions] Inserting a transcript line VERBATIM as a paragraph is unsafe on the git-sync doc->markdown->doc round-trip: the paragraph serializer emits text with no block-escape, so a line starting at col 0 with `- `/`> `/`# `/`1. `/```` ``` ````/`|`/ `> [!info]` silently re-parses into a list/quote/heading/code/table/callout (the last hits the #359 callout machinery). Safe under the host contract (every line prefixed `You:`/`Speaker N:`, which starts with a letter) but the helper didn't enforce it, and leading-whitespace / stray lines exist. Fix: trim each kept line (drops the indent leak), and if it STILL begins with a col-0 markdown block trigger, prepend an invisible zero-width space (U+200B) so the trigger isn't at col 0 — the round-trip keeps it a paragraph. (Backslash-escape was rejected: `marked` consumes the `\` on re-import, so it would render visibly then vanish. ZWSP is invisible and never markdown-escaped.) The serializer's missing block-escape is the pre-existing root cause; this is the boundary defense. Prefixed transcript lines never match the trigger regex → left byte-exact. - [test-coverage] The "inserted as TEXT, not HTML/markdown" contract wasn't locked (all test strings were plain alphabet). Added a test that inserts `bold and *stars* and [link](x)` (with Bold/Italic/Link marks registered so an insertContent(html) regression would parse them) and asserts one verbatim text node, no marks, and getHTML() has no live tags. Verified: apps/client tsc --noEmit 0 errors; gitmost bridge tests 4 passed; a NEW prosemirror-markdown round-trip test (real convertProseMirrorToMarkdown -> markdownToProseMirror) proves bare triggers corrupt while the ZWSP-neutralized form stays a single byte-preserved paragraph and normal You:/Speaker N: lines round-trip byte-exact — 3 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/gitmost/gitmost-recording.test.ts | 87 ++++++++++++++- .../editor/gitmost/gitmost-recording.ts | 52 +++++++-- .../gitmost-transcript-neutralization.test.ts | 104 ++++++++++++++++++ 3 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts index 29149822..bcd31451 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts @@ -4,19 +4,31 @@ import { Document } from "@tiptap/extension-document"; import { Paragraph } from "@tiptap/extension-paragraph"; import { Text } from "@tiptap/extension-text"; import { Heading } from "@tiptap/extension-heading"; +import { Bold } from "@tiptap/extension-bold"; +import { Italic } from "@tiptap/extension-italic"; +import { Link } from "@tiptap/extension-link"; import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts"; +const ZWSP = "​"; // U+200B, the helper's block-trigger neutralizer + /** * #377 — the web-side bridge must append the native host's transcript below the * recording. These exercise the pure insert helper through a REAL Tiptap editor - * (Document/Paragraph/Text/Heading), asserting the resulting document rather - * than mocking the editor: transcript present -> "Transcript" heading + one - * paragraph per non-empty line (verbatim); absent/empty/non-string -> no-op. + * (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing + * regression would be caught), asserting the resulting document rather than + * mocking the editor: transcript present -> "Transcript" heading + one paragraph + * per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown + * parsing); col-0 markdown block triggers are neutralized so git-sync keeps them + * paragraphs; absent/empty/non-string -> no-op. */ describe("gitmostInsertTranscriptIntoEditor", () => { const makeEditor = () => new Editor({ - extensions: [Document, Paragraph, Text, Heading], + // Bold/Italic/Link are registered specifically so that IF the helper ever + // regressed to inserting an HTML/markdown string (instead of a text node), + // TipTap would parse ``/`*..*`/`[..](..)` into marks and the literal- + // text assertions below would fail. + extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link], // Start from a single empty paragraph (a fresh page's baseline). The // helper appends at the end of the doc, i.e. below existing content. content: { type: "doc", content: [{ type: "paragraph" }] }, @@ -49,6 +61,73 @@ describe("gitmostInsertTranscriptIntoEditor", () => { editor.destroy(); }); + it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => { + const editor = makeEditor(); + const line = + "You: bold and *stars* and [link](x)"; + + const inserted = gitmostInsertTranscriptIntoEditor(editor, line); + expect(inserted).toBe(true); + + const paras = (editor.getJSON().content ?? []).filter( + (n: any) => n.type === "paragraph", + ) as any[]; + // The transcript line is exactly ONE paragraph holding a SINGLE text node + // whose text is the verbatim string — not split into bold/link/other nodes, + // not carrying any marks, not raw HTML. This FAILS if the helper switched to + // insertContent(htmlString): TipTap would then parse /[link](x)/*stars*. + const content = paras[paras.length - 1].content; + expect(content).toHaveLength(1); + expect(content[0].type).toBe("text"); + expect(content[0].marks ?? []).toEqual([]); + expect(content[0].text).toBe(line); + // And no bold/italic/link mark exists anywhere in the document. + const html = editor.getHTML(); + expect(html).not.toMatch(/<(strong|b|em|i|a)\b/); + // The angle brackets survived as escaped entities (literal text), not a live + //