fix(converter): block-escape начала параграфа — закрытие класса потерь данных

Строка параграфа, начинающаяся с блочного триггера (`#`/`-`/`*`/`+`/`>`,
упорядоченного `N.`/`N)`, фенса ```/~~~, таблицы `|` или тематического
разрыва `---`/`***`/`___`), на round-trip doc->markdown->doc молча
превращалась в heading/list/quote/code block/table/horizontalRule. Худший
случай — тематический разрыв: horizontalRule не несёт текста, и строка
теряла его целиком.

Сериализатор параграфа теперь backslash-экранирует ведущий блочный триггер
(escapeLeadingBlockTrigger): экранируется только ПЕРВЫЙ значащий символ,
токенизатор CommonMark декодирует `\` обратно в литерал И снимает блочную
интерпретацию, так что строка round-trip'ится байт-в-байт как параграф.
Emphasis `**x**`, inline-code и обычная проза триггерами не являются и не
трогаются (нет мусорных backslash).

Класс раньше не чинили, а ОБХОДИЛИ; обход убран у обоих потребителей:
- клиентский мост (gitmost-recording.ts) больше не подставляет ZWSP-хак;
- генеративный корпус (text-arbitraries.ts) снял самоцензуру — добавлен
  blockTriggerLeadRunArb, параграф теперь МОЖЕТ открываться триггером, и
  P1/P2/P3 сами доказывают закрытие класса.

Пины на каждый триггер — детерминированные round-trip через реальный
конвертер (gitmost-transcript-neutralization.test.ts). Обновлён
документировавший старую потерю gap-тест (spec 13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:43:24 +03:00
parent fe5bd159c4
commit e1b8ef5b8b
6 changed files with 181 additions and 140 deletions
@@ -9,7 +9,7 @@ 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
const ZWSP = "​"; // U+200B — asserted ABSENT (the block-escape lives in the serializer now)
/**
* #377 — the web-side bridge must append the native host's transcript below the
@@ -18,8 +18,9 @@ const ZWSP = "​"; // U+200B, the helper's block-trigger neutralizer
* 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.
* parsing); col-0 markdown block triggers are stored verbatim (the git-sync
* serializer block-escapes them, so no client-side ZWSP is needed);
* absent/empty/non-string -> no-op.
*/
describe("gitmostInsertTranscriptIntoEditor", () => {
const makeEditor = () =>
@@ -91,19 +92,22 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
editor.destroy();
});
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
it("inserts col-0 markdown block triggers as verbatim paragraph text (no ZWSP workaround)", () => {
const editor = makeEditor();
// Trigger lines (some with a leaked indent) + a normal prefixed line.
// Trigger lines (some with a leaked indent) + a normal prefixed line. The
// git-sync serializer now block-escapes a leading trigger itself, so the
// bridge inserts each line's TEXT byte-exact (only the leaked indent is
// trimmed) — no invisible ZWSP is prepended anymore.
const inserted = gitmostInsertTranscriptIntoEditor(
editor,
[
"- dash",
" > quote", // leading indent must be trimmed then neutralized
" > quote", // leading indent is trimmed, text otherwise verbatim
"# hash",
"1. one",
"> [!info] note",
"```js",
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
"---",
"***",
"___",
"You: normal line",
@@ -116,20 +120,23 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
.map((n: any) => n.content?.[0]?.text)
.filter((t: any) => typeof t === "string") as string[];
// Every block-trigger line is prefixed with the invisible ZWSP (indent
// trimmed first); the normal `You:` line is left byte-exact.
// Each trigger line is stored as its own byte-exact text (indent trimmed);
// the git-sync round-trip keeps it a paragraph via the serializer's
// block-escape, so no ZWSP is needed here.
expect(texts).toEqual([
ZWSP + "- dash",
ZWSP + "> quote",
ZWSP + "# hash",
ZWSP + "1. one",
ZWSP + "> [!info] note",
ZWSP + "```js",
ZWSP + "---",
ZWSP + "***",
ZWSP + "___",
"- dash",
"> quote",
"# hash",
"1. one",
"> [!info] note",
"```js",
"---",
"***",
"___",
"You: normal line",
]);
// Guard: no invisible ZWSP leaked into any inserted line.
for (const t of texts) expect(t).not.toContain(ZWSP);
editor.destroy();
});
@@ -240,45 +240,22 @@ export async function gitmostUploadFileToEditor(
}
}
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
const GITMOST_ZWSP = "​";
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
// line, the git-sync markdown serializer (packages/prosemirror-markdown
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
// verbatim with NO block-escape (the pre-existing root cause), so a leading
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
// quote / code block / table / callout. The final alternative matches a WHOLE-
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
// that node carries NO text, an un-neutralized separator line would LOSE its
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
// and never matches, so prefixed lines are left byte-exact.
const GITMOST_MD_BLOCK_TRIGGER_RE =
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
// Append a transcript block BELOW the recording's audio node in a live editor:
// a "Transcript" heading followed by one paragraph per non-empty transcript
// line. The transcript is plain text, `\n`-separated, each line already
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
// both leak into the display and, at col 0, form a markdown block trigger) and,
// if it still begins with a col-0 markdown block trigger, gets an invisible
// zero-width space prepended so the git-sync round-trip cannot turn it into a
// list/quote/heading/callout/code/table (defensive boundary against the
// serializer's missing block-escape). This is best-effort and meant to run
// AFTER the audio has already been inserted; the caller must guard against a
// throw so a transcript failure never fails the (already successful) recording.
// Returns true when a block was inserted, false when there was nothing to
// insert (transcript undefined/empty/not-a-string). A non-string value is a
// no-op, not an error.
// leak into the display). A line that begins with a col-0 markdown block
// trigger (`#`/`-`/`>`/`1.`/fence/`---`/…) needs no client-side workaround: the
// git-sync serializer (packages/prosemirror-markdown, `case "paragraph"`) now
// block-escapes such a leading trigger, so the doc->markdown->doc round-trip
// keeps the line a paragraph on its own — the former invisible-ZWSP defense is
// gone. This is best-effort and meant to run AFTER the audio has already been
// inserted; the caller must guard against a throw so a transcript failure never
// fails the (already successful) recording. Returns true when a block was
// inserted, false when there was nothing to insert (transcript
// undefined/empty/not-a-string). A non-string value is a no-op, not an error.
export function gitmostInsertTranscriptIntoEditor(
editor: Editor,
transcript: unknown,
@@ -288,13 +265,7 @@ export function gitmostInsertTranscriptIntoEditor(
.split("\n")
// Trim each line and drop blank (whitespace-only) ones.
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
// `Speaker N:`) never match and stay byte-exact.
.map((line) =>
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
);
.filter((line) => line.length > 0);
if (lines.length === 0) return false;
const content = [
@@ -63,6 +63,54 @@ export interface ConvertProseMirrorToMarkdownOptions {
* separator is emitted for any other join, so non-list output is unchanged.
*/
const LIST_MARKER_SEPARATOR = "<!-- -->";
/**
* Backslash-escape a leading markdown BLOCK trigger so a serialized paragraph
* line re-parses as a PARAGRAPH, not another block. Without this, a paragraph
* whose text begins at column 0 with an ATX heading `#`, a blockquote/callout
* `>`, a bullet marker `-`/`*`/`+`, an ordered marker `N.`/`N)`, a code fence
* (```` ``` ````/`~~~`), a table `|`, or a thematic break (`---`/`***`/`___`,
* solid or spaced) silently becomes a heading/list/quote/code block/table/rule
* on the next markdown -> ProseMirror import — a known data-loss class (the
* thematic-break case drops the text entirely, since a horizontalRule carries
* none). CommonMark's escape tokenizer decodes the inserted `\` back to the
* literal character on import AND stops the block interpretation, so the line
* round-trips byte-exact as paragraph text. Only the FIRST offending character
* is escaped (the minimum needed to break block recognition); a line that does
* NOT open a block — emphasis `**x**`, an inline code span, ordinary prose — is
* returned verbatim, so there is no backslash churn for the common case.
*
* Applied ONLY to paragraph text: headings/lists/blockquotes legitimately open
* with these markers and render them from their own cases. This is the single,
* canonical fix for the class the client bridge worked around with a ZWSP
* (`gitmost-recording.ts`) and the generative suite self-censored around
* (`text-arbitraries.ts`) — both now removed.
*/
function escapeLeadingBlockTrigger(line: string): string {
// ATX heading: 1..6 `#` then whitespace/EOL.
if (/^#{1,6}(?:\s|$)/.test(line)) return "\\" + line;
// Blockquote / Docmost callout opener (`>` or `> [!info]`).
if (line.startsWith(">")) return "\\" + line;
// Bullet list marker then whitespace/EOL. Emphasis (`*x*`, `**x**`) has no
// space after the leading marker and is intentionally left verbatim.
if (/^[-*+](?:\s|$)/.test(line)) return "\\" + line;
// Ordered list marker `N.` / `N)`: escape the DELIMITER so the digits stay
// literal (`1. x` -> `1\. x`, which imports back as the text `1. x`).
const ordered = line.match(/^(\d+)[.)](?:\s|$)/);
if (ordered) {
const digits = ordered[1].length;
return line.slice(0, digits) + "\\" + line.slice(digits);
}
// Fenced code block: 3+ backticks or tildes. A single/double backtick is an
// inline code span and is left verbatim.
if (/^(?:`{3,}|~{3,})/.test(line)) return "\\" + line;
// Thematic break: a WHOLE line of 3+ identical `-`/`*`/`_`, optionally spaced.
if (/^([-*_])(?:\s*\1){2,}\s*$/.test(line)) return "\\" + line;
// GFM table row opener.
if (line.startsWith("|")) return "\\" + line;
return line;
}
function listMarkerFamily(type: string | undefined): "ul" | "ol" | null {
if (type === "bulletList" || type === "taskList") return "ul";
if (type === "orderedList") return "ol";
@@ -412,7 +460,10 @@ export function convertProseMirrorToMarkdown(
}
case "paragraph": {
const text = renderInlineChildren(nodeContent);
// Escape a leading block trigger so a paragraph whose text opens with
// `#`/`-`/`>`/`1.`/`|`/a fence/`---` round-trips as a paragraph instead
// of silently re-parsing into another block on the next import.
const text = escapeLeadingBlockTrigger(renderInlineChildren(nodeContent));
const align = node.attrs?.textAlign;
// Non-default alignment round-trips as an ATTACHED HTML comment at the
// END of the block line (#293 canon #9):
@@ -212,14 +212,42 @@ export function normalizeInline(nodes: any[]): any[] {
return out;
}
/**
* #493 commit 1: a plain-text run whose text DELIBERATELY OPENS with a markdown
* BLOCK trigger — ATX heading `#`, bullet `-`/`*`/`+`, blockquote `>`, ordered
* `N.`/`N)`, or a table `|` — followed by safe text. Pre-#493 the corpus
* self-censored these away (safeTextArb's leading-word guarantee); the paragraph
* serializer now BLOCK-ESCAPES a leading trigger, so the generative round-trip
* itself proves the data-loss class is closed rather than avoiding it.
*
* DELIBERATELY excludes the code-fence (backtick) trigger — the backtick is a
* code-span delimiter that re-pairs globally (see specialCharArb's note), an
* instability UNRELATED to block-escape — and the whole-line thematic break
* (`---`), which only triggers when the line is ONLY dashes; both are covered by
* the deterministic pin (gitmost-transcript-neutralization.test.ts). Each still
* ENDS in a word (safeTextArb) so adjacent-run concatenation stays safe.
*/
export const blockTriggerLeadRunArb: fc.Arbitrary<any> = fc
.tuple(
fc.constantFrom('# ', '## ', '- ', '* ', '+ ', '> ', '1. ', '1) ', '| '),
safeTextArb,
)
.map(([trigger, rest]) => ({ type: 'text', text: trigger + rest }));
/**
* Inline content for a paragraph: at least one marked text run, optionally with
* inline atoms (math/mention) and hard breaks interspersed. Always starts with a
* text run so the paragraph never opens with a block trigger. (Ported.)
* inline atoms (math/mention) and hard breaks interspersed. The FIRST run is
* usually an ordinary marked run, but sometimes a block-trigger-leading run
* (blockTriggerLeadRunArb) so the paragraph OPENS with a markdown block trigger
* — exercising the serializer's leading block-escape end-to-end. (Ported, with
* the #493 leading-trigger dimension added.)
*/
export const inlineContentArb: fc.Arbitrary<any[]> = fc
.tuple(
markedTextRunArb,
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: blockTriggerLeadRunArb },
),
fc.array(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
@@ -5,32 +5,21 @@ import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
/**
* gitmost #377 (round-1 review, finding #1) — proof, against the REAL
* converter, that the transcript-insert boundary defense survives git-sync.
* #493 commit 1 — the paragraph serializer's leading-block-escape closes the
* data-loss class where a paragraph whose text opens at column 0 with a markdown
* block trigger (`#`/`-`/`*`/`+`/`>`, an ordered `N.`/`N)`, a code fence, a
* table `|`, a callout opener, or a thematic break) silently re-parsed into a
* heading / list / quote / code block / table / horizontalRule on the git-sync
* doc -> markdown -> doc cycle. The thematic-break case was the worst: a
* horizontalRule carries NO text, so the line's text was lost entirely.
*
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
* that text VERBATIM with no block-escape, so a line whose text begins with a
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
* cycle, silently re-parse into a heading / list / quote / callout / code block.
* That missing block-escape is the pre-existing root cause; the bridge's
* boundary defense prepends an invisible zero-width space (U+200B) to a line
* that begins with such a trigger, shifting it off column 0.
*
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
* different package and can't be imported here) and asserts:
* 1. bare trigger lines DO corrupt (documents the root cause), and
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
* text byte-preserved.
* This is the deterministic PIN, one assertion per trigger, exercised through
* the REAL converter round-trip (not a mock): each bare trigger line now
* round-trips as a SINGLE paragraph with its text byte-preserved — proving the
* class is closed WITHOUT the former client-side ZWSP workaround (removed) or
* the generative suite's leading-word self-censorship (removed).
*/
const ZWSP = "​"; // U+200B
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
const MD_BLOCK_TRIGGER_RE =
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
const para = (t: string) => ({
type: "paragraph",
@@ -43,78 +32,74 @@ const roundtrip = async (text: string) => {
return back.content as any[];
};
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
// Lines that, at column 0, the serializer's missing block-escape would let
// git-sync re-parse into a non-paragraph block.
describe("paragraph block-escape (git-sync round-trip)", () => {
// Every line here, at column 0, WOULD (pre-fix) re-parse into a non-paragraph
// block. Each is now block-escaped by the serializer and round-trips clean.
const triggerLines = [
"- dash",
"* star",
"+ plus",
"> quote",
"# hash",
"## two hash",
"###### six hash",
"1. one",
"1) one",
"> [!info] note",
"```js",
"~~~",
// Solid + spaced thematic breaks — these re-parse into a `horizontalRule`,
// which carries NO text, so a bare separator line LOSES its text entirely
// (round-2 finding). `_` also only forms a block via this construct.
"| a | b |",
// Solid + spaced thematic breaks — the text-LOSING case pre-fix.
"---",
"***",
"___",
"- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break)
"- - -",
"_ _ _",
];
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
it("every bare trigger line round-trips as a single paragraph, text byte-preserved", async () => {
for (const line of triggerLines) {
const blocks = await roundtrip(line);
// At least one produced block is NOT a paragraph — i.e. corruption.
const allParagraphs = blocks.every((b) => b.type === "paragraph");
expect(
allParagraphs,
`expected "${line}" to corrupt when inserted bare`,
).toBe(false);
}
});
it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => {
// The severe case: no text node survives. Documents why neutralization
// matters more here than for list/quote (where the text survived).
for (const line of ["---", "***", "___"]) {
const blocks = await roundtrip(line);
expect(blocks.map((b) => b.type)).toContain("horizontalRule");
// No block carries the original text anywhere.
const flat = JSON.stringify(blocks);
expect(flat).not.toContain(line);
}
});
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
for (const line of triggerLines) {
// The regex must actually classify each as a trigger.
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
true,
expect(blocks, `"${line}" should be one block`).toHaveLength(1);
expect(blocks[0].type, `"${line}" should stay a paragraph`).toBe(
"paragraph",
);
const neutralized = ZWSP + line;
const blocks = await roundtrip(neutralized);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("paragraph");
// Text is byte-preserved (ZWSP + original line), so the display is the
// original line with only an invisible leading character.
expect(blocks[0].content[0].text).toBe(neutralized);
expect(
blocks[0].content?.[0]?.text,
`"${line}" text should survive byte-exact`,
).toBe(line);
}
});
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
it("emphasis / inline-code paragraphs are NOT escaped (no backslash churn)", async () => {
// These open with `*`/`` ` `` but are NOT block triggers; the serialized
// markdown must not gain a stray leading backslash, and they round-trip.
for (const [text, mark] of [
["bold", "bold"],
["italic", "italic"],
["code", "code"],
] as const) {
const node = doc({
type: "paragraph",
content: [{ type: "text", text, marks: [{ type: mark }] }],
});
const md = convertProseMirrorToMarkdown(node);
expect(md.startsWith("\\"), `${mark} must not be block-escaped`).toBe(
false,
);
const back = await markdownToProseMirror(md);
expect(back.content[0].type).toBe("paragraph");
expect(back.content[0].content[0].text).toBe(text);
expect(back.content[0].content[0].marks?.[0]?.type).toBe(mark);
}
});
it("normal host-prefixed lines round-trip byte-exact (unaffected)", async () => {
for (const line of [
"You: hello there",
"Speaker 1: - and then a dash mid-line",
"Speaker 2: 1. not a list",
]) {
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
const blocks = await roundtrip(line);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("paragraph");
@@ -430,7 +430,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
});
});
describe('converter gap coverage — documented round-trip data loss (specs 12–14)', () => {
describe('converter gap coverage — formerly-lossy round-trips, now closed (specs 12–14)', () => {
// 12. A 3-backtick fence inside a codeBlock body is now lengthened: the outer
// fence widens to (longest inner run + 1) backticks per CommonMark, so the
// inner ``` is treated as content and the block survives as ONE node.
@@ -460,25 +460,24 @@ describe('converter gap coverage — documented round-trip data loss (specs 12
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
});
// 13. A leading ordered-list marker in paragraph text is NOT escaped, so a
// plain paragraph silently becomes an orderedList on re-import.
it('a paragraph starting with "1. " is promoted to an orderedList on re-import', async () => {
// 13. #493 commit 1: a leading ordered-list marker in paragraph text is now
// BLOCK-ESCAPED, so the paragraph round-trips as a paragraph instead of
// silently becoming an orderedList (was documented data loss, now closed).
it('a paragraph starting with "1. " is block-escaped and stays a paragraph', async () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'text', text: '1. not a list' }],
});
const md1 = convertProseMirrorToMarkdown(d);
expect(md1).toBe('1. not a list'); // no backslash escape
expect(md1).toBe('1\\. not a list'); // the ordered-list delimiter is escaped
const doc2 = await markdownToProseMirror(md1);
expect(doc2.content?.[0]?.type).toBe('orderedList');
const li = doc2.content[0].content?.[0];
expect(li?.type).toBe('listItem');
expect(li.content?.[0]?.content?.[0]).toMatchObject({
expect(doc2.content?.[0]?.type).toBe('paragraph');
expect(doc2.content[0].content?.[0]).toMatchObject({
type: 'text',
text: 'not a list', // the "1. " was consumed as a list marker
text: '1. not a list', // the escape decodes back to the literal text
});
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
expect(docsCanonicallyEqual(d, doc2)).toBe(true);
});
// 14. #293 canon #4: the image title now round-trips via the attached