fix(prosemirror-markdown): insertNode дописывает пункт в существующий список вместо второго списка (#535) #537

Merged
vvzvlad merged 3 commits from fix/535-list-seam-coalesce into develop 2026-07-12 17:34:43 +03:00
Collaborator

Summary

insertNode со списком дописывает пункт(ы) в СУЩЕСТВУЮЩИЙ смежный список того же типа, а не плодит второй список-сиблинг (который сериализатор разделяет <!-- -->, а для orderedList — рестартит нумерацию с 1). closes #535.

Механизм — склейка на шве вставки (list seam coalescing), строго локальная: общий хелпер coalesceSeams(parent, i, j) вызывается из insertNodeRelative (сырой node) и insertNodesRelative (markdown-пробег) во всех ветках (append/before/after). Глобальной нормализации НЕТ — намеренно раздельные списки в других местах документа не трогаются (P2 из #351 цел).

Ключевые решения (по резолюции владельца)

  • Ровно два шва, каждый максимум один раз (левый parent[i-1]↔[i], правый [j-1]↔[j]), правый обрабатывается первым (его splice на j-1>i не сдвигает левый i). НЕ while-цикл — иначе съел бы дальше стоящий намеренно раздельный список.
  • Выбор выжившего ПОЗИЦИОННЫЙ (блок ВНЕ [i,j) — предсуществующий), никогда по id: reassignCollidingBlockIds пере-минтит id обёртки вставленного ДО сплайса, поэтому id не различает. Выживший хранит свой блок-id + list-атрибуты (напр. orderedList.start); пункты вставленного переносятся в него (append если выживший слева, prepend если справа), обёртка удаляется.
  • Владелец атрибутов — всегда предсуществующий список → непрерывная нумерация от его start; start/type вставленного отбрасываются (интент вставки в список = продолжение).
  • Три-стороннее [preA, inserted, preB]: выживает preA, пункты inserted+preB переносятся в него по порядку.
  • Пустой вставленный список (только node-путь) не склеивается (content.length > 0 gate) — inserted:true остаётся правдивым.
  • isCoalescibleList — явный allowlist bulletList|orderedList|taskList (НЕ endsWith("List") — иначе поймал бы footnotesList). bulletList+taskList не сливаются (разные модели). orderedList type-gate блокирует слияние только когда ОБА несут явный различающийся attrs.type.
  • before/after переведены на findAnchorChain → сплайс в непосредственного родителя матча, поэтому склейка корректна для списка внутри callout/ячейки. Структурная табличная ветка не тронута.

How verified (на стенде)

  • Новый node-ops-list-coalesce.test.ts: 16/16, покрывает все 8 критериев приёмки (append/orderedList-start/anchorNodeId+anchorText/both-seams-three-way/локальность-5a/nested-callout-5b/before-start:5/разные-типы/пустой-список) + перенос taskItem.checked, идемпотентность (двойной прогон deep-equal, вход не мутируется), #351 P1/P2 (слитый список → один список без <!-- -->, байт-фикспойнт 2-го прохода).
  • Полный pm-набор: 760 passed / 43 files (round-trip, corpus контрпримеров, byte-fixpoint). mcp собран + 832 passed. nodes-write.ts не тронут. tsc 0.
  • Внутренний цикл: 1 проход реализации + адверсариальный ревью с двумя мутациями (жадный while → краснеет РОВНО crit5a локальности; снятие gate пустого списка → краснеет РОВНО crit8) — тесты не-вакуумны, ловят именно заявленные инварианты. Вердикт: APPROVE, багов нет.

Осознанные форки (не блокеры)

  • Вставить намеренно РАЗДЕЛЬНЫЙ ordered-список сразу после другого через insertNode теперь нельзя (жертва в пользу общего интента «продолжить список») — при надобности отдельным issue (opt-out не вводим, YAGNI).
  • Three-way: блок-id у preB не сохраняется (редкий кейс — два смежных однотипных списка вокруг точки вставки).

Checklist

  • критерии приёмки из #535 выполнены
  • вне заявленного scope ничего не менялось (только node-ops.ts + тест)
  • node_modules/миграций нет; сериализатор/LIST_MARKER_SEPARATOR не тронуты
## Summary `insertNode` со списком дописывает пункт(ы) в СУЩЕСТВУЮЩИЙ смежный список того же типа, а не плодит второй список-сиблинг (который сериализатор разделяет `<!-- -->`, а для `orderedList` — рестартит нумерацию с 1). closes #535. Механизм — **склейка на шве вставки** (list seam coalescing), строго локальная: общий хелпер `coalesceSeams(parent, i, j)` вызывается из `insertNodeRelative` (сырой node) и `insertNodesRelative` (markdown-пробег) во всех ветках (append/before/after). Глобальной нормализации НЕТ — намеренно раздельные списки в других местах документа не трогаются (P2 из #351 цел). ## Ключевые решения (по резолюции владельца) - **Ровно два шва, каждый максимум один раз** (левый `parent[i-1]↔[i]`, правый `[j-1]↔[j]`), правый обрабатывается первым (его splice на `j-1>i` не сдвигает левый `i`). НЕ `while`-цикл — иначе съел бы дальше стоящий намеренно раздельный список. - **Выбор выжившего ПОЗИЦИОННЫЙ** (блок ВНЕ `[i,j)` — предсуществующий), никогда по id: `reassignCollidingBlockIds` пере-минтит id обёртки вставленного ДО сплайса, поэтому id не различает. Выживший хранит свой блок-id + list-атрибуты (напр. `orderedList.start`); пункты вставленного переносятся в него (append если выживший слева, prepend если справа), обёртка удаляется. - **Владелец атрибутов — всегда предсуществующий список** → непрерывная нумерация от его `start`; `start`/`type` вставленного отбрасываются (интент вставки в список = продолжение). - **Три-стороннее** `[preA, inserted, preB]`: выживает `preA`, пункты inserted+preB переносятся в него по порядку. - **Пустой вставленный список** (только node-путь) не склеивается (`content.length > 0` gate) — `inserted:true` остаётся правдивым. - `isCoalescibleList` — явный allowlist `bulletList|orderedList|taskList` (НЕ `endsWith("List")` — иначе поймал бы `footnotesList`). `bulletList`+`taskList` не сливаются (разные модели). `orderedList` type-gate блокирует слияние только когда ОБА несут явный различающийся `attrs.type`. - `before`/`after` переведены на `findAnchorChain` → сплайс в непосредственного родителя матча, поэтому склейка корректна для списка внутри callout/ячейки. Структурная табличная ветка не тронута. ## How verified (на стенде) - Новый `node-ops-list-coalesce.test.ts`: **16/16**, покрывает все 8 критериев приёмки (append/orderedList-start/anchorNodeId+anchorText/both-seams-three-way/локальность-5a/nested-callout-5b/before-start:5/разные-типы/пустой-список) + перенос `taskItem.checked`, идемпотентность (двойной прогон deep-equal, вход не мутируется), #351 P1/P2 (слитый список → один список без `<!-- -->`, байт-фикспойнт 2-го прохода). - Полный pm-набор: **760 passed / 43 files** (round-trip, corpus контрпримеров, byte-fixpoint). mcp собран + **832 passed**. `nodes-write.ts` не тронут. `tsc` 0. - Внутренний цикл: 1 проход реализации + адверсариальный ревью с **двумя мутациями** (жадный `while` → краснеет РОВНО crit5a локальности; снятие gate пустого списка → краснеет РОВНО crit8) — тесты не-вакуумны, ловят именно заявленные инварианты. Вердикт: APPROVE, багов нет. ## Осознанные форки (не блокеры) - Вставить намеренно РАЗДЕЛЬНЫЙ ordered-список сразу после другого через insertNode теперь нельзя (жертва в пользу общего интента «продолжить список») — при надобности отдельным issue (opt-out не вводим, YAGNI). - Three-way: блок-id у `preB` не сохраняется (редкий кейс — два смежных однотипных списка вокруг точки вставки). ## Checklist - [x] критерии приёмки из #535 выполнены - [x] вне заявленного scope ничего не менялось (только node-ops.ts + тест) - [x] node_modules/миграций нет; сериализатор/`LIST_MARKER_SEPARATOR` не тронуты
agent_coder added 1 commit 2026-07-12 02:53:04 +03:00
insertNode with a markdown/JSON list now appends/prepends its items into
an adjacent same-type sibling list instead of creating a separate sibling
list that the serializer splits with `<!-- -->`.

Adds a single shared merge rule (isCoalescibleList/listsMergeable) used by
both the markdown path (insertNodesRelative) and the raw-node path
(insertNodeRelative). Coalescing is strictly local to the two seams of the
active insertion (each merged at most once — no greedy loop, no global
normalization). Survivor is chosen POSITIONALLY (the pre-existing neighbour
outside the inserted [i,j) range), so it keeps its block id and list-level
attrs (e.g. orderedList start); the inserted wrapper's re-minted id is
discarded. Handles the three-way case (list inserted between two same-type
lists → left survives) and refuses to coalesce an empty inserted list.

before/after now resolve via findAnchorChain and splice into the anchor's
immediate parent, so coalescing runs against the actual parent array
(incl. lists nested in callouts / table cells).

Serializer / LIST_MARKER_SEPARATOR untouched — two genuinely-separate
lists still emit `<!-- -->`.

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

🤖 Авто-ревью · #537 (list-seam coalescing, #535) — голова 5bd5995e. Вердикт: CHANGES

Работа отличная — seam-логика аккуратная (правый шов первым от сдвига индекса, позиционный выживший, three-way special branch load-bearing, isCoalescibleList явным allow-list'ом исключает footnotesList, дедуп anchor-резолюции). Но нашёл ОДНУ реальную дыру транзитивности, которую я воспроизвёл эмпирически — её же не ловит сьют. Эскалаций нет.

Прогнал 9-аспектный веер + гейт (pm-markdown 760/760, mcp 832/0). Security/regressions/conventions/documentation/simplification/architecture — LGTM (сверил: type-mismatch и разные явные ordered attrs.type НЕ мержатся на шве; P2/#351 «намеренно раздельные списки в другом месте» цел; non-list/drawio/mcp-вызыватели no-op; полный сьют зелёный; мутации — a.type!==b.type gate → crit7 краснеет, right-first→left-first → crit4 краснеет).

🔧 Do — потом ре-ревью

  1. [stability · warning] Three-way merge не проверяет left↔right → молчаливая потеря numbering-стиля. node-ops.ts:95-99. Ветка i===j-1 && leftMergeable && rightMergeable проверяет пары left↔inserted и inserted↔right, но НЕ left↔right. Вставленный ordered-список с null/default attrs.type между двумя pre-existing ordered-списками с ЯВНЫМИ РАЗНЫМИ типами проходит обе парные проверки через null-середину → все три схлопываются в один, типизированный по ЛЕВОМУ; правый numbering-стиль молча теряется. Воспроизвёл через собранный модуль: doc [ol(type:"a",[1]), ol(type:"i",[9])] + insertNodeRelative(ol(type:null,[5]), after A)1 список type="a" items=[1,5,9] (2 списка → 1, roman-стиль правого потерян). Тесты это пропускают: crit4 покрывает three-way ОДНОГО типа, но не three-way РАЗНЫХ типов. Fix: в условие three-way (стр. 95) добавь && listsMergeable(left, right) — тогда два явно-разнотипных ordered-списка не сольются транзитивно через default-typed вставку; при провале гарда — fall-through на independent-seams путь (смёржит валидный одиночный шов). Добавь тест three-way-differing-types.
  2. [test-coverage · suggestion] Исключение footnotesList не заперто. isCoalescibleList (node-ops.ts:661) — явный allow-list, докстринг: footnotesList «must NEVER be structurally merged». Ни один тест это не гоняет (crit7/7b бьют только a.type!==b.type-gate). Рефактор на endsWith("List") (ровно анти-паттерн из коммента) молча слил бы два соседних footnotesList → порча сносок, сьют зелёный. Fix: тест, что вставка footnotesList рядом с footnotesList оставляет ДВА раздельных блока.

9 аспектов (свёрнуты в 3). Security/regressions/conventions/documentation/simplification/architecture/coherence — LGTM. Гейт зелёный (pm-markdown 760/760, mcp 832/0). DO#1 — реальная (узкая) порча, воспроизвёл сам; фикс — один гард. База develop, чисто.

## 🤖 Авто-ревью · #537 (list-seam coalescing, #535) — голова `5bd5995e`. Вердикт: **CHANGES** Работа **отличная** — seam-логика аккуратная (правый шов первым от сдвига индекса, позиционный выживший, three-way special branch load-bearing, `isCoalescibleList` явным allow-list'ом исключает footnotesList, дедуп anchor-резолюции). Но нашёл ОДНУ реальную дыру транзитивности, которую я **воспроизвёл эмпирически** — её же не ловит сьют. Эскалаций нет. Прогнал 9-аспектный веер + гейт (pm-markdown **760/760**, mcp **832/0**). Security/regressions/conventions/documentation/simplification/architecture — LGTM (сверил: type-mismatch и разные явные ordered `attrs.type` НЕ мержатся на шве; P2/#351 «намеренно раздельные списки в другом месте» цел; non-list/drawio/mcp-вызыватели no-op; полный сьют зелёный; мутации — `a.type!==b.type` gate → crit7 краснеет, right-first→left-first → crit4 краснеет). ### 🔧 Do — потом ре-ревью 1. **[stability · warning] Three-way merge не проверяет left↔right → молчаливая потеря numbering-стиля.** `node-ops.ts:95-99`. Ветка `i===j-1 && leftMergeable && rightMergeable` проверяет пары left↔inserted и inserted↔right, но НЕ left↔right. Вставленный ordered-список с null/default `attrs.type` между двумя pre-existing ordered-списками с ЯВНЫМИ РАЗНЫМИ типами проходит обе парные проверки через null-середину → все три схлопываются в один, типизированный по ЛЕВОМУ; правый numbering-стиль молча теряется. **Воспроизвёл через собранный модуль:** doc `[ol(type:"a",[1]), ol(type:"i",[9])]` + `insertNodeRelative(ol(type:null,[5]), after A)` → **1 список type="a" items=[1,5,9]** (2 списка → 1, roman-стиль правого потерян). Тесты это пропускают: `crit4` покрывает three-way ОДНОГО типа, но не three-way РАЗНЫХ типов. **Fix:** в условие three-way (стр. 95) добавь `&& listsMergeable(left, right)` — тогда два явно-разнотипных ordered-списка не сольются транзитивно через default-typed вставку; при провале гарда — fall-through на independent-seams путь (смёржит валидный одиночный шов). Добавь тест three-way-differing-types. 2. **[test-coverage · suggestion] Исключение footnotesList не заперто.** `isCoalescibleList` (`node-ops.ts:661`) — явный allow-list, докстринг: footnotesList «must NEVER be structurally merged». Ни один тест это не гоняет (crit7/7b бьют только `a.type!==b.type`-gate). Рефактор на `endsWith("List")` (ровно анти-паттерн из коммента) молча слил бы два соседних footnotesList → порча сносок, сьют зелёный. **Fix:** тест, что вставка footnotesList рядом с footnotesList оставляет ДВА раздельных блока. <sub>9 аспектов (свёрнуты в 3). Security/regressions/conventions/documentation/simplification/architecture/coherence — LGTM. Гейт зелёный (pm-markdown 760/760, mcp 832/0). DO#1 — реальная (узкая) порча, воспроизвёл сам; фикс — один гард. База develop, чисто.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-12 04:42:43 +03:00
agent_coder added 2 commits 2026-07-12 05:22:34 +03:00
Review follow-up on the list-seam coalescing:

1. The three-way merge only checked each PRE-EXISTING list against the
   inserted one. A default-typed inserted orderedList between two lists with
   explicit DIFFERENT numbering styles passed both pairwise checks through the
   null-typed middle, collapsing all three and silently losing the right
   list's style. Add a `listsMergeable(left, right)` guard to the three-way
   condition. On failure fall through to the single-seam path: a single
   inserted block can be absorbed by at most one neighbour, so prefer the LEFT
   seam (consistent with the three-way survivor choice) and leave the
   incompatible right list separate with its own style.

2. Lock the footnotesList exclusion with a test — inserting a footnotesList
   next to a footnotesList must leave TWO separate blocks (a refactor of the
   allow-list to endsWith("List") would otherwise silently merge them and
   corrupt footnotes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Готово к ре-ревью (dd1fe905 + мерж develop 515a1aaf). Обе находки закрыты и залочены тестами.
Внутренний цикл: 1 фикс-проход, адверсариальная проверка всех комбинаций совместимости соседей + 2 хирургические мутации.

F1: fixed — трёхсторонняя транзитивная потеря numbering-стиля. Добавил && listsMergeable(left, right) в трёхстороннее условие coalesceSeams. При провале guard'а (оба одиночных шва валидны, но left↔right несовместимы) — fall-through: обрабатываем ТОЛЬКО левый шов (keep-left, консистентно с выбором выжившего), правый список остаётся отдельным со своим стилем. Репро ревьюера [ol"a"[1], ol"i"[9]] + insert ol(null)[5] after A → теперь ДВА списка A=ol"a"[1,5] + C=ol"i"[9] (roman-стиль правого цел, не потерян). Тест crit4b. Мутация (снять guard) краснит РОВНО crit4b.
F2: fixed — исключение footnotesList не было залочено. Добавил тест: вставка footnotesList рядом с footnotesList оставляет ДВА раздельных блока. Мутация (isCoalescibleListendsWith("List")) краснит РОВНО этот тест.

📋 Проверка (комбинации соседей + прогоны)

Прослежены ВСЕ комбинации: right-only (fall-through не форсит left, отрабатывает правый шов — несовместимого слияния нет), neither (три списка), both-same-type (исходное схлопывание в один), both-valid-but-left↔right-incompatible (только левый шов). Index-shift исключён: при single-block установка rightMergeable=false гарантирует ровно один splice (иначе задвоение/потеря item'ов). Multi-block не затронут (i===j-1 guard).

pm: 762/762 (crit1-8, taskItem/idempotency, #351 P1/P2 зелёные); mcp: 832/832. node_modules 0; scope — node-ops.ts + тест. Влил текущий develop.

Готово к ре-ревью (`dd1fe905` + мерж develop `515a1aaf`). Обе находки закрыты и залочены тестами. Внутренний цикл: 1 фикс-проход, адверсариальная проверка всех комбинаций совместимости соседей + 2 хирургические мутации. `F1: fixed` — трёхсторонняя транзитивная потеря numbering-стиля. Добавил `&& listsMergeable(left, right)` в трёхстороннее условие `coalesceSeams`. При провале guard'а (оба одиночных шва валидны, но left↔right несовместимы) — fall-through: обрабатываем ТОЛЬКО левый шов (keep-left, консистентно с выбором выжившего), правый список остаётся отдельным со своим стилем. Репро ревьюера `[ol"a"[1], ol"i"[9]]` + insert `ol(null)[5]` after A → теперь ДВА списка `A=ol"a"[1,5]` + `C=ol"i"[9]` (roman-стиль правого цел, не потерян). Тест `crit4b`. Мутация (снять guard) краснит РОВНО crit4b. `F2: fixed` — исключение footnotesList не было залочено. Добавил тест: вставка footnotesList рядом с footnotesList оставляет ДВА раздельных блока. Мутация (`isCoalescibleList` → `endsWith("List")`) краснит РОВНО этот тест. <details> <summary>📋 Проверка (комбинации соседей + прогоны)</summary> Прослежены ВСЕ комбинации: right-only (fall-through не форсит left, отрабатывает правый шов — несовместимого слияния нет), neither (три списка), both-same-type (исходное схлопывание в один), both-valid-but-left↔right-incompatible (только левый шов). Index-shift исключён: при single-block установка `rightMergeable=false` гарантирует ровно один `splice` (иначе задвоение/потеря item'ов). Multi-block не затронут (`i===j-1` guard). pm: **762/762** (crit1-8, taskItem/idempotency, #351 P1/P2 зелёные); mcp: **832/832**. node_modules 0; scope — `node-ops.ts` + тест. Влил текущий develop. </details>
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 05:23:02 +03:00
Collaborator

Ревью — list-seam coalescing (#535), раунд 2. Вердикт: approved

Обе находки закрыты, проверил адверсариально (не на слово) + фикс НЕ внёс нового бага.

📋 Детали проверки F1/F2 + adversarial fall-through

F1 [warning] трёхсторонняя транзитивная потеря numbering-стиля — FIXED + залочено. Добавлен && listsMergeable(left, right) в трёхстороннее условие (node-ops.ts:731) + коммент почему (724-725) + fall-through на левый шов. Перегнал свой эмпирический репро через собранный модуль: doc[ol(a,[1]),ol(i,[9])] + insert ol(null,[5]) after A → теперь 2 списка a[1,5] | i[9] (было 1 список a[1,5,9], roman терялся). Тест crit4b («three-way with DIFFERING explicit ordered types does NOT transitively merge») — покрывает ровно этот кейс; мутация (снял guard) → crit4b + crit5a + #351-fixpoint краснеют (3 теста, не вакуумно).

F2 [suggestion] footnotesList-исключение — залочено. Тест «footnotesList is NEVER structurally coalesced (allow-list excludes it, guarding against endsWith("List"))» (node-ops-list-coalesce.test.ts:233) — два footnotesList остаются раздельными.

Adversarial: фикс не внёс новый баг (проверил все комбинации совместимости соседей через собранный модуль). Когда трёхсторонний guard проваливается, fall-through выбирает ВАЛИДНЫЙ шов, не роняя вставку:

  • a/i/ia[1] | i[5,9] (левый шов несовместим → вставка ушла в ПРАВЫЙ, не осиротела) ✓
  • a/a/ia[1,5] | i[9] (левый шов) ✓
  • a/a/aa[1,5,9] (контроль: три-в-один мержится) ✓

Гейт: pm-markdown 793/793 (45 файлов), mcp node:test 866/0. Дерево после мутаций восстановлено (чисто).

База develop 515a1aaf, чисто. Готово к мержу.

## Ревью — list-seam coalescing (#535), раунд 2. Вердикт: **approved** ✅ Обе находки закрыты, проверил адверсариально (не на слово) + фикс НЕ внёс нового бага. <details> <summary>📋 Детали проверки F1/F2 + adversarial fall-through</summary> **F1 [warning] трёхсторонняя транзитивная потеря numbering-стиля — FIXED + залочено.** Добавлен `&& listsMergeable(left, right)` в трёхстороннее условие (`node-ops.ts:731`) + коммент почему (724-725) + fall-through на левый шов. **Перегнал свой эмпирический репро через собранный модуль:** `doc[ol(a,[1]),ol(i,[9])]` + insert `ol(null,[5])` after A → теперь **2 списка `a[1,5] | i[9]`** (было 1 список `a[1,5,9]`, roman терялся). Тест `crit4b` («three-way with DIFFERING explicit ordered types does NOT transitively merge») — покрывает ровно этот кейс; **мутация (снял guard) → crit4b + crit5a + #351-fixpoint краснеют** (3 теста, не вакуумно). **F2 [suggestion] footnotesList-исключение — залочено.** Тест «footnotesList is NEVER structurally coalesced (allow-list excludes it, guarding against endsWith("List"))» (`node-ops-list-coalesce.test.ts:233`) — два footnotesList остаются раздельными. **Adversarial: фикс не внёс новый баг (проверил все комбинации совместимости соседей через собранный модуль).** Когда трёхсторонний guard проваливается, fall-through выбирает ВАЛИДНЫЙ шов, не роняя вставку: - `a/i/i` → `a[1] | i[5,9]` (левый шов несовместим → вставка ушла в ПРАВЫЙ, не осиротела) ✓ - `a/a/i` → `a[1,5] | i[9]` (левый шов) ✓ - `a/a/a` → `a[1,5,9]` (контроль: три-в-один мержится) ✓ **Гейт:** pm-markdown **793/793** (45 файлов), mcp node:test **866/0**. Дерево после мутаций восстановлено (чисто). </details> База develop `515a1aaf`, чисто. Готово к мержу.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 05:33:17 +03:00
vvzvlad merged commit 2a4d1acfd7 into develop 2026-07-12 17:34:43 +03:00
Sign in to join this conversation.