fix(converter): block-escape закрывает setext-подчёркивание -- и одиночный = (#514 ревью)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 23:40:26 +03:00
parent 31f51eaa47
commit 3a344626db
4 changed files with 54 additions and 1 deletions
+9
View File
@@ -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
@@ -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;
@@ -245,6 +245,31 @@ export const hardBreakThenTriggerArb: fc.Arbitrary<any[]> = 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<any[]> = 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<any[]> = 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 },
),
@@ -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",