fix(prosemirror-markdown): hardBreak переживает round-trip (trailing/consecutive/table-cell) (#549) #553

Open
agent_coder wants to merge 1 commits from fix/549-hardbreak-md-canon into develop
Collaborator

Что (#549)

Канон markdown молча терял hardBreak в трёх подслучаях round-trip pm→md→pm (тот же класс тихой потери данных, что #503, но на markdown-, а не JSON-пути; всплыло при ревью #544):

  1. Trailing в конце абзаца/документа — top-level .trim() срезал завершающий \n.
  2. Consecutivea \n \nb при реимпорте (marked GFM) парсился как ДВА абзаца.
  3. Table cell — GFM-ячейка флэттила перевод в пробел.

Фикс: представление hardBreak — позиционно-зависимое ( \n<br> в lossy-позициях)

Эмпирически прогнал все три кандидата ( \n, \, <br>) через РЕАЛЬНЫЙ сериализатор + импортёр. <br> — единственная форма, переживающая все три провальных контекста (backslash \ не тянет trailing). Поэтому:

  • mid-paragraph (безопасный, работает и сейчас) → оставлен \n — golden-выхлоп байт-стабилен, существующий тест не тронут.
  • trailing / перед другим hardBreak / перед пустым рендером (renderInlineChildren) → <br>.
  • ячейка таблицы.replace(/ {2}\n/g,"<br>") ПЕРЕД схлопыванием переводов строк в пробел.

Импортёр <br>→hardBreak уже есть — правка только на стороне сериализации. Тела сносок (inFootnoteBody) не тронуты (там breaks и так схлопываются — не регрессия).

Per-sub-case (кол-во hardBreak, pm→md→pm)

  • mid-paragraph (регресс-гард): 1→1, md остаётся "a \nb"
  • trailing: 0→1, "a""a<br>"
  • consecutive: 0→2, "a \n \nb""a<br> \nb"
  • table cell: 0→1, "a b""a<br>b"

Как проверял (staging)

Реальный round-trip через markdownToProseMirror (marked→HTML→generateJSON) — все три контекста + краевые (triple break, break-then-text-then-break, table-cell consecutive/trailing) сохраняют hardBreak точь-в-точь. Полный сьют prosemirror-markdown 779/779, свежий tsc exit 0.

Форк + заметка ревьюеру

  • Форк: позиционно-зависимая форма ( \n mid vs <br> в lossy-позициях). Плюс — байт-стабильность golden-выхлопа; минус — markdown чуть менее «чистый» (инлайн-HTML), но для этих позиций надёжной альтернативы нет.
  • SUGGESTION (не баг, на реальных данных недостижимо): hardBreak, за которым следует финальный инлайн-узел, рендерящийся в непустой whitespace-only (напр. текст из одного пробела), остаётся \n, а не <br>. Вне трёх документированных багов.

внутренний цикл: 1 проход, APPROVE — каждая из 3 правок изолирована мутацией (нейтрализуешь ветку → краснеет ровно её тест), mid-paragraph и footnote-пути без регрессий.

closes #549

## Что (#549) Канон markdown молча терял `hardBreak` в трёх подслучаях round-trip pm→md→pm (тот же класс тихой потери данных, что #503, но на markdown-, а не JSON-пути; всплыло при ревью #544): 1. **Trailing** в конце абзаца/документа — top-level `.trim()` срезал завершающий ` \n`. 2. **Consecutive** — `a \n \nb` при реимпорте (marked GFM) парсился как ДВА абзаца. 3. **Table cell** — GFM-ячейка флэттила перевод в пробел. ## Фикс: представление `hardBreak` — позиционно-зависимое (` \n` → `<br>` в lossy-позициях) Эмпирически прогнал все три кандидата (` \n`, `\`, `<br>`) через РЕАЛЬНЫЙ сериализатор + импортёр. `<br>` — единственная форма, переживающая все три провальных контекста (backslash `\` не тянет trailing). Поэтому: - **mid-paragraph** (безопасный, работает и сейчас) → оставлен ` \n` — golden-выхлоп байт-стабилен, существующий тест не тронут. - **trailing / перед другим hardBreak / перед пустым рендером** (`renderInlineChildren`) → `<br>`. - **ячейка таблицы** → `.replace(/ {2}\n/g,"<br>")` ПЕРЕД схлопыванием переводов строк в пробел. Импортёр `<br>`→hardBreak уже есть — правка только на стороне сериализации. Тела сносок (`inFootnoteBody`) не тронуты (там breaks и так схлопываются — не регрессия). ### Per-sub-case (кол-во hardBreak, pm→md→pm) - mid-paragraph (регресс-гард): `1→1`, md остаётся `"a \nb"` - trailing: `0→1`, `"a"` → `"a<br>"` - consecutive: `0→2`, `"a \n \nb"` → `"a<br> \nb"` - table cell: `0→1`, `"a b"` → `"a<br>b"` ## Как проверял (staging) Реальный round-trip через `markdownToProseMirror` (marked→HTML→generateJSON) — все три контекста + краевые (triple break, break-then-text-then-break, table-cell consecutive/trailing) сохраняют hardBreak точь-в-точь. Полный сьют prosemirror-markdown 779/779, свежий `tsc` exit 0. ## Форк + заметка ревьюеру - **Форк:** позиционно-зависимая форма (` \n` mid vs `<br>` в lossy-позициях). Плюс — байт-стабильность golden-выхлопа; минус — markdown чуть менее «чистый» (инлайн-HTML), но для этих позиций надёжной альтернативы нет. - **SUGGESTION (не баг, на реальных данных недостижимо):** hardBreak, за которым следует финальный инлайн-узел, рендерящийся в непустой whitespace-only (напр. текст из одного пробела), остаётся ` \n`, а не `<br>`. Вне трёх документированных багов. внутренний цикл: 1 проход, APPROVE — каждая из 3 правок изолирована мутацией (нейтрализуешь ветку → краснеет ровно её тест), mid-paragraph и footnote-пути без регрессий. closes #549
agent_coder added 1 commit 2026-07-12 07:18:09 +03:00
The markdown canon lost `hardBreak` nodes in three pm->md->pm sub-cases where
the two-space `  \n` form does not survive re-import via marked:

1. Trailing at end of a paragraph — the top-level `.trim()` strips the trailing
   `  \n`, and even a non-last paragraph's trailing break sits before the `\n\n`
   block gap, so marked drops it.
2. Consecutive breaks (`a<hardBreak><hardBreak>b`) — the whitespace-only middle
   line reads as a paragraph separator, splitting the run into two paragraphs.
3. Inside a GFM table cell — the single-line cell collapses the break to a space.

Emit `<br>` (inline HTML break) in exactly those positions: marked passes it
through and generateJSON rebuilds a hardBreak in every context, including table
cells. renderInlineChildren now picks `<br>` for a break that is the last inline
child or is followed by another break; a safe mid-paragraph break keeps the
byte-stable `  \n` form (golden output unchanged). Footnote bodies are skipped
(their `^[…]` form already collapses breaks to spaces). The table-cell case
converts the `  \n` marker to `<br>` before the newline collapse.

Adds pm->md->pm count/position round-trip tests for all three sub-cases plus a
mid-paragraph regression guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-12 07:18:10 +03:00
Collaborator

Ревью — hardBreak переживает round-trip (#549). Вердикт: approved

Чинит ровно те 3 подслучая тихой потери, что я завёл в #549 при ревью #544. Проверил своим round-trip'ом + 2 агента (один с parent-baseline diff'ом) + мутация. Нового бага НЕ вносит. Эскалаций нет.

📋 Проверка + adversarial

Фикс: hardBreak позиционно-зависим — \n в БЕЗОПАСНОМ mid-paragraph (golden байт-стабилен, не тронут), <br> в 3 lossy-позициях (<br> реимпортится в hardBreak в любой позиции; backslash \ не тянет trailing). Два хунка: table-cell .replace(/ {2}\n/g,"<br>") перед newline-collapse; inline-assembly loop !next || next.type==="hardBreak" || parts[i+1]===""<br>. Skip в footnote-телах (уже lossy).

Мой эмпирический round-trip (собранный модуль): mid-para a \nb (hb 1→1, unchanged), trailing a<br> (1→1), consecutive a<br> \nb (2→2), table-cell | a<br>b | (1→1) — ВСЕ сохраняют hardBreak (были потеряны).

Adversarial (агенты, parent-baseline diff — нового бага нет):

  • 3/4/5 consecutive: parent терял ВСЕ (3→0), фикс сохраняет ВСЕ (3→3). ✓
  • over-fire на пустой text-ноде: emit <br> где \n хватило бы → всё равно 1→1 (br round-trips везде); пустые text-ноды к тому же schema-invalid. Безвредно.
  • heading/blockquote/listItem trailing: теперь 1→1 (parent терял). Улучшение.
  • user-typed литеральный <br>→hardBreak: диверджит, но ИДЕНТИЧНО в parent И fix — пред-существующий баг (case "text" не экранирует </>), #553 его НЕ вносит. Завёл отдельно → #554.
  • Мутация: нейтрализовал оба хунка → 3 из 4 новых теста (trailing/consecutive/table) краснеют, mid-para regression-guard зелёный. Не вакуумно.

Гейт: pm-markdown 779/779 (44 файла) — ни один golden byte-stability не сломан. Coherence/conventions/documentation/simplification/architecture — LGTM (позиц-чувствительность уже ядро дизайна сериализатора). Дерево чисто.

Готово к мержу. (Вне scope: #554 — литеральный <br> в тексте становится hardBreak, пред-существующее, не блокирует.)

## Ревью — hardBreak переживает round-trip (#549). Вердикт: **approved** ✅ Чинит ровно те 3 подслучая тихой потери, что я завёл в #549 при ревью #544. Проверил своим round-trip'ом + 2 агента (один с parent-baseline diff'ом) + мутация. Нового бага НЕ вносит. Эскалаций нет. <details> <summary>📋 Проверка + adversarial</summary> **Фикс:** hardBreak позиционно-зависим — ` \n` в БЕЗОПАСНОМ mid-paragraph (golden байт-стабилен, не тронут), `<br>` в 3 lossy-позициях (`<br>` реимпортится в hardBreak в любой позиции; backslash `\` не тянет trailing). Два хунка: table-cell `.replace(/ {2}\n/g,"<br>")` перед newline-collapse; inline-assembly loop `!next || next.type==="hardBreak" || parts[i+1]===""` → `<br>`. Skip в footnote-телах (уже lossy). **Мой эмпирический round-trip (собранный модуль):** mid-para `a \nb` (hb 1→1, unchanged), trailing `a<br>` (1→1), consecutive `a<br> \nb` (2→2), table-cell `| a<br>b |` (1→1) — ВСЕ сохраняют hardBreak (были потеряны). **Adversarial (агенты, parent-baseline diff — нового бага нет):** - 3/4/5 consecutive: parent терял ВСЕ (3→0), фикс сохраняет ВСЕ (3→3). ✓ - over-fire на пустой text-ноде: emit `<br>` где ` \n` хватило бы → всё равно 1→1 (br round-trips везде); пустые text-ноды к тому же schema-invalid. Безвредно. - heading/blockquote/listItem trailing: теперь 1→1 (parent терял). Улучшение. - user-typed литеральный `<br>`→hardBreak: диверджит, но ИДЕНТИЧНО в parent И fix — пред-существующий баг (`case "text"` не экранирует `<`/`>`), #553 его НЕ вносит. Завёл отдельно → **#554**. - **Мутация:** нейтрализовал оба хунка → 3 из 4 новых теста (trailing/consecutive/table) краснеют, mid-para regression-guard зелёный. Не вакуумно. **Гейт:** pm-markdown **779/779** (44 файла) — ни один golden byte-stability не сломан. Coherence/conventions/documentation/simplification/architecture — LGTM (позиц-чувствительность уже ядро дизайна сериализатора). Дерево чисто. </details> Готово к мержу. (Вне scope: #554 — литеральный `<br>` в тексте становится hardBreak, пред-существующее, не блокирует.)
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 07:36:07 +03:00
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/549-hardbreak-md-canon:fix/549-hardbreak-md-canon
git checkout fix/549-hardbreak-md-canon
Sign in to join this conversation.