From 3a344626db3f07962315ccbeda62408efd38e8aa Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 23:40:26 +0300 Subject: [PATCH] =?UTF-8?q?fix(converter):=20block-escape=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20setext-=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=87=D1=91=D1=80=D0=BA=D0=B8=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20--=20=D0=B8=20=D0=BE=D0=B4=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=3D=20(#514=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL из ревью: escapeLeadingBlockTrigger не покрывал setext-underline из ровно двух дефисов (--) и одиночного = → строка-продолжение после hardBreak, равная -- или =, репарсилась как setext-heading, а текст предыдущей строки терялся. Тот же класс потери данных, что PR и чинит. Добавлена setext-рука после тематической: целая строка ^-+[ \t]*$ или ^=+[ \t]*$ экранирует ведущий символ. Заякорено на всю строку → mid-content -/= не задевается; после тематической руки → ---/---- не двойно-экранируются; идемпотентно против уже-экранированного \=\= (инлайн-escape отрабатывает раньше). Пины --/----/=/==== + генеративный hardBreakThenSetextArb, оба mutation-verified. CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++ .../src/lib/markdown-converter.ts | 14 ++++++++++ .../test/generative/text-arbitraries.ts | 26 +++++++++++++++++++ .../gitmost-transcript-neutralization.test.ts | 6 ++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 795c7502..379fcd18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -302,6 +302,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Markdown round-trips no longer silently drop a line that opens with a block + trigger.** When a document is exported to Markdown and re-imported (git-sync + stabilize, agent writes), a paragraph or continuation line (after a hard break) + that begins with a block marker — an ATX heading `#`, a blockquote/callout `>`, + a list marker (`-`/`*`/`+`/`N.`/`N)`), a code fence, a table `|`, a thematic + break (`---`), or a setext underline (`--`, `----`, or a lone `=`) — is now + backslash-escaped so it round-trips as text instead of being re-parsed into a + heading/list/quote/rule and losing its content. Front-matter stripping is + scoped to the import path only. (#493) - **The server no longer runs out of heap during long autonomous agent runs.** A new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative snapshot of the ENTIRE turn text on every streamed text-delta when no output diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index 001a85d4..3f647a75 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -145,6 +145,20 @@ function escapeLeadingBlockTrigger(line: string): string { 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; + // Setext underline: a continuation line (after a hardBreak) that is ONLY `-` + // or ONLY `=` (any count, trailing spaces allowed). Under a paragraph line + // such a line re-parses as a SETEXT HEADING and SILENTLY DROPS its own text + // (`a\n--` -> heading "a", the `--` is LOST; `a\n=` -> heading "a", `=` LOST). + // The bullet arm above catches a lone `-` (via its `$`) and the thematic arm + // catches 3+ dashes, but exactly TWO dashes (`--`) fall through both; and no + // arm covers a lone `=` at all (a `==` pair is neutralized earlier by the + // inline `==`->`\=\=` escape, so only a single `=` line reaches here). Escaping + // the leading char (`\--`, `\=`) breaks the setext interpretation so the line + // round-trips as paragraph text. The WHOLE line must be the marker (anchored + // `^-+`/`^=+` to EOL), so a mid-content `-`/`=` is never spuriously escaped; + // and a `---`/`----` already handled by the thematic arm never reaches here, + // so there is no double-escape. + if (/^-+[ \t]*$/.test(line) || /^=+[ \t]*$/.test(line)) return "\\" + line; // GFM table row opener. if (line.startsWith("|")) return "\\" + line; return line; diff --git a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts index f88de26f..63067427 100644 --- a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts +++ b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts @@ -245,6 +245,31 @@ export const hardBreakThenTriggerArb: fc.Arbitrary = fc .tuple(hardBreakArb, blockTriggerLeadRunArb) .map(([hb, trigger]) => [hb, trigger]); +/** + * #493 (setext data-loss): a WHOLE-LINE setext underline landing on a + * continuation line. A setext underline is a line of ONLY `-` (any count) or + * ONLY `=` (any count) that FOLLOWS a paragraph line; on re-parse it turns the + * preceding line into a heading and DROPS its own text. The block-escape must + * neutralize it. Unlike blockTriggerLeadRunArb, the underline must occupy the + * whole line, so we sandwich it between two hardBreaks (underline on its own + * line, preceded by earlier paragraph content, followed by a trailing word so + * the closing hardBreak is not dropped by normalizeInline). Covers underlines + * of every length: `--` (the two-dash case the bullet/thematic arms miss), a + * lone `=`, `==`/`====` (neutralized by the inline `==` escape), and `---`/ + * `----` (regression for the existing thematic case). + */ +export const hardBreakThenSetextArb: fc.Arbitrary = fc + .tuple( + fc.constantFrom('--', '=', '==', '====', '---', '----'), + safeTextArb, + ) + .map(([underline, rest]) => [ + { type: 'hardBreak' }, + { type: 'text', text: underline }, + { type: 'hardBreak' }, + { type: 'text', text: rest }, + ]); + /** * Inline content for a paragraph: at least one marked text run, optionally with * inline atoms (math/mention) and hard breaks interspersed. The FIRST run is @@ -268,6 +293,7 @@ export const inlineContentArb: fc.Arbitrary = fc { weight: 1, arbitrary: mentionArb.map((n) => [n]) }, { weight: 1, arbitrary: hardBreakArb.map((n) => [n]) }, { weight: 2, arbitrary: hardBreakThenTriggerArb }, + { weight: 2, arbitrary: hardBreakThenSetextArb }, ), { minLength: 0, maxLength: 4 }, ), diff --git a/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts b/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts index 0f946354..20b9f092 100644 --- a/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts +++ b/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts @@ -106,7 +106,11 @@ describe("paragraph block-escape (git-sync round-trip)", () => { ["a", "> b"], ["a", "1. b"], ["a", "| b |"], - ["a", "---"], // setext / thematic — the text-losing case + ["a", "---"], // setext / thematic (3 dashes) — the text-losing case + ["a", "--"], // setext underline, EXACTLY two dashes (bullet/thematic miss it) + ["a", "----"], // setext / thematic (4 dashes) + ["a", "="], // setext H1 underline, a lone `=` (no other arm covers it) + ["a", "===="], // setext H1 underline, run of `=` ]) { const d = doc({ type: "paragraph",