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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user