fix(prosemirror-markdown): insertNode со списком плодит сиблинг + <!-- --> вместо дописывания пункта — склейка на шве вставки #535

Open
opened 2026-07-12 02:23:50 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

insertNode с markdown-списком не дописывает пункт(ы) в существующий список, а
создаёт ОТДЕЛЬНЫЙ второй список-сиблинг на том же уровне. Пример:

  1. Страница: - один\n- два\n- триdoc.content: [ bulletList[один,два,три] ].
  2. insertNode(position:"append", markdown:"- четыре").
  3. Результат: doc.content: [ bulletList[один,два,три], bulletList[четыре] ] — два
    списка. В экспортном markdown между ними появляется разделитель <!-- --> (его
    намеренно эмитит сериализатор LIST_MARKER_SEPARATOR, чтобы два соседних <ul>
    не слились при реимпорте). Для orderedList хуже: второй список получает свою
    нумерацию с 1.

Баг НЕ специфичен для append: position:"after"/"before" рядом со списком
воспроизводят тот же артефакт — markdown-фрагмент - x всегда импортируется как
целый блок bulletList и сплайсится соседним блоком (проверено на собранном пакете).

Последствие для агентов: N добавлений в один логический список = N отдельных списков

  • N разделителей; приходится перечитывать и переписывать блок целиком.

Цель: вставка списка рядом с существующим списком ТОГО ЖЕ типа узла вливает пункты в
существующий список, а не плодит сиблинг.

Контекст и исследование

Затронутый пакет — packages/prosemirror-markdown (единственная копия конвертера и
node-ops). Точки:

  • packages/prosemirror-markdown/src/lib/node-ops.ts:
    • insertNodesRelative(doc, nodes, opts) (~стр. 798) — путь markdown-фрагмента (N
      блоков). Ветки: append (~811) out.content.push(...fresh); anchorNodeId
      (~823) и anchorText (~847) content.splice(i+offset, 0, ...fresh).
    • insertNodeRelative(doc, node, opts) (~стр. 662) — путь сырого node (один
      блок). Ветки append (~677), anchorNodeId (~741), anchorText (~765).
      Отдельная ветка структурных табличных узлов (tableRow/tableCell/tableHeader) —
      её склейка НЕ касается.
    • findAnchorChain(doc, opts) (~стр. 593) — уже используется структурной веткой
      (~701), возвращает цепочку {node,index}[] ССЫЛКАМИ в клон (мутабельно): по ней
      достаётся непосредственный родитель совпадения (chain[len-2].node) и индекс
      (chain[len-1].index). Семантика: anchorNodeId — pre-order DFS, ПЕРВОЕ
      совпадение (идентична текущему walkContent); anchorText — только
      ВЕРХНЕУРОВНЕВЫЕ блоки, exact-then-stripped (идентична текущему).
  • Диспетчер: packages/mcp/src/client/nodes-write.ts, метод insertNode (~528–654).
    markdown → insertNodesRelative; nodeinsertNodeRelative (~621–623). Перед
    вставкой (~617) вызывается reassignCollidingBlockIds(liveDoc, blocks) — он
    пере-минтит id ТОЛЬКО у верхнеуровневых блоков вставляемого пробега. После вставки
    mergeFootnoteDefinitions. Трансформа выполняется внутри mutatePageContent и
    МОЖЕТ переисполниться на collab-ретрае.
  • Источник <!-- -->: packages/prosemirror-markdown/src/lib/markdown-converter.ts
    (LIST_MARKER_SEPARATOR, ~58–90, 399). Это КОРРЕКТНОЕ поведение сериализатора для
    двух реально разных соседних списков — его НЕ трогаем; чиним источник, где
    создаётся лишний сиблинг. bulletList и taskList — одно маркерное семейство
    (ul), но разные типы узла.

Установленные факты (проверено на собранном пакете):

  • markdown-фрагмент - x импортируется как целый bulletList; у блока-обёртки есть
    attrs.id, у listItem нет attrs, у его параграфа id:null. При склейке
    (сброс обёртки, перенос listItem-ов) ни id обёртки, ни вложенные id не создают
    коллизий.
  • ordered-фрагмент СОХРАНЯЕТ start ("3. три"start:3); стиль нумерации
    orderedList.attrs.type по умолчанию null (обычный decimal).
  • task-список даёт taskList/taskItem{checked} — иная модель, чем
    bulletList/listItem; импортёр bridgeTaskLists делает taskList только когда
    ВСЕ пункты с чекбоксом, поэтому смешанных списков не порождает.
  • round-trip P1/P2 (#351) на слитом списке держится: номер пункта в дереве не
    хранится, сериализатор считает start+index, поэтому слитый orderedList{start:S}
    сериализуется в непрерывный S, S+1, … и реимпортируется в один список.

Смежное:

  • #351 (закрыт) — round-trip инварианты пакета: P1 (семантический round-trip) и P2
    (байтовый fixpoint со второго прохода). Не ослаблять; корпус контрпримеров —
    packages/prosemirror-markdown/test/fixtures/counterexamples/.
  • ordered-list-start-normalization.test.ts — нормализация вырожденного start
    (0/отриц./дробь → 1) на эмите; ортогональна склейке, конфликта нет.
  • #188 — заметки об инструментах (обратная связь по insertNode), действий не требует.

Дизайн

Ввести СКЛЕЙКУ НА ШВЕ ВСТАВКИ (list seam coalescing): после того как список-блок
поставлен на место, если он оказывается непосредственно смежным с существующим
списком-сиблингом ТОГО ЖЕ типа узла — влить пункты, а не оставлять два блока. Склейка
строго локальна к шву вставки; глобальной нормализации документа НЕТ (иначе разрушим
намеренно раздельные списки в других местах и P2 из #351).

Общий хелпер (единственный источник правила склейки, R3):

// Exactly the three list containers we coalesce. NOT `type.endsWith("List")`,
// which also matches `footnotesList` (footnotes must never be structurally merged).
function isCoalescibleList(n: any): boolean {
  return isObject(n) && (n.type === "bulletList" ||
    n.type === "orderedList" || n.type === "taskList");
}
// Two lists merge ONLY when node types are identical. For orderedList also require
// a compatible numbering style: treat missing/null `attrs.type` as the default and
// BLOCK the merge only when BOTH carry an explicit, differing `type`.
function listsMergeable(a: any, b: any): boolean {
  if (!isCoalescibleList(a) || !isCoalescibleList(b)) return false;
  if (a.type !== b.type) return false;
  if (!Array.isArray(a.content) || !Array.isArray(b.content)) return false;
  if (a.type === "orderedList") {
    const ta = a.attrs?.type ?? null, tb = b.attrs?.type ?? null;
    if (ta != null && tb != null && ta !== tb) return false;
  }
  return true;
}

Процедура склейки. Вставка размещается в родительском массиве parent (для
appenddoc.content; для before/afterchain[len-2].node.content, т.е.
непосредственный родитель совпадения якоря, полученный через findAnchorChain),
занимая индексы [i, j).

ЖЁСТКИЕ ОГРАНИЧЕНИЯ (обязательны — иначе фикс снова открывает дыры):

  • Ровно два шва, по одному разу. Склейка срабатывает ТОЛЬКО на левом шве
    (parent[i-1]parent[i]) и правом шве (parent[j-1]parent[j]), каждый
    максимум один раз. НЕ реализовывать как while (соседи одного типа) merge
    такой цикл после сдвига индексов «съест» дальше стоящий НАМЕРЕННО раздельный
    список и сломает критерий 5(a). Пары, где ОБА блока существовали до вставки (вне
    шва), не склеиваются.
  • Выбор выжившего — ПОЗИЦИОННЫЙ, никогда по id. reassignCollidingBlockIds
    пере-минтит id обёртки вставляемого списка ДО сплайса, поэтому по id отличить
    вставленный список от настоящего нельзя. Выживший определяется исключительно
    диапазоном [i,j): сосед ВНЕ диапазона — предсуществующий (survivor), граничный
    блок ВНУТРИ диапазона — вставленный. Выживший сохраняет свой блок id и
    list-уровневые атрибуты; пункты вставленного списка переносятся в выжившего
    (в НАЧАЛО, если выживший справа — случай before; в КОНЕЦ, если выживший слева —
    append/after), затем вставленная обёртка удаляется. Пере-минченный id обёртки
    таким образом всегда отбрасывается и в документ не попадает.
  • Трёхсторонний случай [preA, insertedList, preB] (вставка попала ровно между
    двумя предсуществующими списками одного типа): выживает ЛЕВЫЙ предсуществующий
    (preA); пункты вставленного и затем пункты preB переносятся в preA, preB
    удаляется. Блок id у preB НЕ сохраняется — редкий кейс (требует двух смежных
    однотипных списков вокруг точки вставки), задокументирован.
  • Пустой вставляемый список (возможно только на node-пути): если у
    вставленного списка ноль пунктов — склейку НЕ выполнять, оставить блок как
    вставлен (inserted:true остаётся правдивым).

Владелец атрибутов: всегда предсуществующий список. Для orderedList это значит
непрерывную нумерацию от его start; атрибуты вставленного списка (start/type)
отбрасываются — осознанно: типичный интент вставки в список — продолжение, а не
рестарт. Атрибуты listItem/taskItem (в т.ч. checked) переносятся вместе с
пунктами.

Где применяется:

  • insertNodesRelative (markdown-путь): append (левый шов с прежним хвостом),
    before/after (оба шва пробега). Пробег может содержать не только списки (напр.
    - a\n\nтекст\n\n- b → [bulletList, paragraph, bulletList]) — шов срабатывает
    только когда ГРАНИЧНЫЙ блок пробега сам список; внутренние блоки не трогаются.
    Ветки before/after перевести на findAnchorChain (сплайс в непосредственный
    родитель совпадения, chain[len-2].content по индексу chain[len-1].index+offset),
    чтобы склейка шла по фактическому родительскому массиву — это заодно корректно
    обрабатывает список, вложенный в callout / ячейку таблицы. Семантика
    first-match / top-level-only при этом сохраняется (проверено: идентична текущему
    walkContent).
  • insertNodeRelative (сырой node-путь): те же ветки для одиночного узла-списка,
    через тот же хелпер. Структурная табличная ветка не затрагивается.

Оба пути используют ОДИН хелпер склейки, чтобы поведение markdown и node было
идентичным.

Семантика отказов (R4): склейка — чистая трансформация над клоном дерева, сетевых
эффектов нет. Запускается ТОЛЬКО после успешного размещения (когда inserted уже
true); если якорь не найден — вставки нет, склейки нет, документ не меняется.
Идемпотентность для collab-ретрая: трансформа переисполняется на свежем liveDoc,
склейка детерминирована поверх результата вставки того же прогона, отдельного
состояния не хранит.

Границы scope

  • НЕ трогаем сериализатор и LIST_MARKER_SEPARATOR — для двух реально раздельных
    соседних списков разделитель по-прежнему корректен.
  • НЕ вводим глобальную нормализацию «схлопнуть все соседние списки в документе» —
    только два шва активной вставки.
  • НЕ вводим opt-out «вставить список отдельным сиблингом» (YAGNI). Как следствие,
    вставить намеренно РАЗДЕЛЬНЫЙ ordered-список сразу после другого ordered-списка
    через insertNode будет нельзя — редкий кейс, приносится в жертву общему интенту
    «продолжить список». Понадобится — отдельным issue.
  • НЕ склеиваем bulletList+taskList (разные модели содержимого).
  • Пути patchNode/updatePageMarkdown/updatePageJson не затрагиваются.

Критерии приёмки

Юнит-уровень пакета (packages/prosemirror-markdown/test/, рядом с
node-ops-splice.test.ts и node-ops.test.ts) + round-trip.

  1. insertNodesRelative([bulletList[1,2,3]], [bulletList[4]], {position:"append"})
    doc.content содержит РОВНО ОДИН bulletList [1,2,3,4] (doc.content.length
    === 1). Явный ассерт на длину, не на verify.
  2. То же для orderedList: один orderedList, start исходного сохранён, пункты
    1..4 непрерывно; НЕТ второго списка с start:1/3.
  3. after/before с anchorNodeId на список того же типа → один слитый список; с
    anchorText, резолвящимся в список-блок → то же.
  4. Оба шва: doc=[listA, <point>, listB] того же типа, вставка списка между ними →
    один список [listA-items, inserted-items, listB-items] в исходном порядке.
  5. Локальность (регресс-гард):
    (a) документ с ДВУМЯ намеренно раздельными списками одного типа (реимпорт из
    markdown с <!-- --> между ними), НЕ смежными с точкой вставки, после
    несвязанной вставки в другом месте остаётся ДВУМЯ списками и по-прежнему эмитит
    <!-- -->;
    (b) вложенный родитель: вставка списка рядом со списком ВНУТРИ callout сливает
    пункты в массиве содержимого callout, верхний уровень не тронут.
  6. before для orderedList{start:5} → слитый список сохраняет start:5 (проверка
    позиционного выбора выжившего — выживает предсуществующий, не вставленный).
  7. Разные типы НЕ склеиваются: taskList рядом с bulletList → два блока (и
    <!-- --> в сериализации — корректно).
  8. taskList+taskList склейка: каждый taskItem.checked сохраняется после
    слияния и round-trip.
  9. Смешанный пробег markdown (- a\n\nтекст\n\n- b) у якоря-списка: граничные списки
    склеиваются с соседями, параграф в середине — отдельный блок.
  10. Путь node (insertNodeRelative с сырым bulletList) склеивается идентично
    markdown-пути. Пустой вставляемый список (content:[]) рядом с bulletList НЕ
    исчезает: остаётся отдельным блоком (или задокументированное поведение),
    inserted:true.
  11. Сериализация слитого списка НЕ содержит <!-- --> между пунктами; round-trip
    P1/P2 (#351) зелёный. Golden-фикстура на один/два/три + четыре (было: два
    списка + <!-- -->; стало: один список).
  12. Якорь не найден → документ не изменён, inserted:false.
  13. E2E MCP (packages/mcp/test-e2e.mjs или packages/mcp/test/mock/ markdown-patch-insert.test.mjs): insertNode append "- четыре" к странице со
    списком → getPageJson показывает один bulletList с 4 пунктами; getPage
    (markdown) не содержит <!-- -->.

Отказы и наблюдаемость

  • Новых сетевых/фоновых путей нет; склейка синхронна и чиста. Оператор ничего нового
    не видит.
  • verify (DiffResult/VerifyReport) отдаёт summary.{inserted,deleted, blocksChanged} и integrity-счётчики — ОТДЕЛЬНОГО поля «число верхнеуровневых
    блоков» там нет. Слияние отразится как blocksChanged + прибавка символов; поле
    ответа blocks остаётся числом ИМПОРТИРОВАННЫХ блоков (вход). Инвариант «число
    блоков не выросло» проверяется юнит-ассертом на doc.content.length (крит. 1), а
    не через verify. Audit trail не требуется (операция не деструктивная).

Совместимость и миграции

  • Смена поведения: insertNode списком рядом с одноимённым списком теперь СКЛЕИВАЕТ
    вместо создания сиблинга. Это исправление бага; кода, полагавшегося на появление
    <!-- -->, не ожидается. Отметить в PR.
  • orderedList: непрерывная нумерация вместо рестарта с 1 — смена наблюдаемого
    вывода (в лучшую сторону), отметить.
  • ЯВНО: вставка ordered-списка с явным стилем нумерации type (напр. "a", alpha)
    рядом с предсуществующим списком дефолтного стиля (type:null, decimal) НЕ
    блокируется — списки сольются, и пункты вставленного будут отрисованы стилем
    выжившего (decimal). Осознанная потеря явного атрибута вставляемого списка
    (согласуется с правилом «атрибуты вставленного отбрасываются»). Отметить в PR.
  • Миграций БД/схемы нет; блокировок горячих таблиц нет; правка чисто в трансформации
    дерева. Rollback — обычный откат PR.

Решённые развилки

  • Насколько широко чинить → ВСЕ позиции (append/before/after); правка в общих
    splice-хелперах, after/before дают тот же баг → человек.
  • markdown-путь vs node-путь → ОБА (общий хелпер, единое поведение) → человек.
  • Одно маркерное семейство, разный тип (bullet↔task) → склеивать ТОЛЬКО идентичный
    тип узла → разные модели содержимого → человек.
  • Владелец атрибутов при слиянии → ПРЕДСУЩЕСТВУЮЩИЙ список (позиционно по диапазону
    [i,j)), не «левый»; иначе before затирал бы id/start существующего списка →
    дизайн (adversarial-разбор).
  • Область склейки → только два шва вставки, по одному разу, без итерации наружу →
    сохранить P2 (#351) и намеренно раздельные списки → дизайн.
  • ordered-стиль type → блокировать слияние лишь при двух ЯВНЫХ разных type
    (default null сливается) → не защищать частый decimal-кейс лишней строгостью →
    дизайн.

Связи

  • Опирается на инварианты #351 (round-trip) — не ослаблять; переиспользовать
    round-trip/golden-инфраструктуру для критерия 11.
  • Контекст-фидбек: #188.

🤖 Составлено orchestrator-feature-designer (дизайн прошёл adversarial self-attack: 3 критика + повторная атака на интегрированный пакет).

## Проблема / Цель `insertNode` с markdown-списком не дописывает пункт(ы) в существующий список, а создаёт ОТДЕЛЬНЫЙ второй список-сиблинг на том же уровне. Пример: 1. Страница: `- один\n- два\n- три` → `doc.content: [ bulletList[один,два,три] ]`. 2. `insertNode(position:"append", markdown:"- четыре")`. 3. Результат: `doc.content: [ bulletList[один,два,три], bulletList[четыре] ]` — два списка. В экспортном markdown между ними появляется разделитель `<!-- -->` (его намеренно эмитит сериализатор `LIST_MARKER_SEPARATOR`, чтобы два соседних `<ul>` не слились при реимпорте). Для `orderedList` хуже: второй список получает свою нумерацию с 1. Баг НЕ специфичен для `append`: `position:"after"/"before"` рядом со списком воспроизводят тот же артефакт — markdown-фрагмент `- x` всегда импортируется как целый блок `bulletList` и сплайсится соседним блоком (проверено на собранном пакете). Последствие для агентов: N добавлений в один логический список = N отдельных списков + N разделителей; приходится перечитывать и переписывать блок целиком. Цель: вставка списка рядом с существующим списком ТОГО ЖЕ типа узла вливает пункты в существующий список, а не плодит сиблинг. ## Контекст и исследование Затронутый пакет — `packages/prosemirror-markdown` (единственная копия конвертера и node-ops). Точки: - `packages/prosemirror-markdown/src/lib/node-ops.ts`: - `insertNodesRelative(doc, nodes, opts)` (~стр. 798) — путь markdown-фрагмента (N блоков). Ветки: `append` (~811) `out.content.push(...fresh)`; `anchorNodeId` (~823) и `anchorText` (~847) `content.splice(i+offset, 0, ...fresh)`. - `insertNodeRelative(doc, node, opts)` (~стр. 662) — путь сырого `node` (один блок). Ветки `append` (~677), `anchorNodeId` (~741), `anchorText` (~765). Отдельная ветка структурных табличных узлов (tableRow/tableCell/tableHeader) — её склейка НЕ касается. - `findAnchorChain(doc, opts)` (~стр. 593) — уже используется структурной веткой (~701), возвращает цепочку `{node,index}[]` ССЫЛКАМИ в клон (мутабельно): по ней достаётся непосредственный родитель совпадения (`chain[len-2].node`) и индекс (`chain[len-1].index`). Семантика: `anchorNodeId` — pre-order DFS, ПЕРВОЕ совпадение (идентична текущему `walkContent`); `anchorText` — только ВЕРХНЕУРОВНЕВЫЕ блоки, exact-then-stripped (идентична текущему). - Диспетчер: `packages/mcp/src/client/nodes-write.ts`, метод `insertNode` (~528–654). markdown → `insertNodesRelative`; `node` → `insertNodeRelative` (~621–623). Перед вставкой (~617) вызывается `reassignCollidingBlockIds(liveDoc, blocks)` — он пере-минтит id ТОЛЬКО у верхнеуровневых блоков вставляемого пробега. После вставки — `mergeFootnoteDefinitions`. Трансформа выполняется внутри `mutatePageContent` и МОЖЕТ переисполниться на collab-ретрае. - Источник `<!-- -->`: `packages/prosemirror-markdown/src/lib/markdown-converter.ts` (`LIST_MARKER_SEPARATOR`, ~58–90, 399). Это КОРРЕКТНОЕ поведение сериализатора для двух реально разных соседних списков — его НЕ трогаем; чиним источник, где создаётся лишний сиблинг. `bulletList` и `taskList` — одно маркерное семейство (`ul`), но разные типы узла. Установленные факты (проверено на собранном пакете): - markdown-фрагмент `- x` импортируется как целый `bulletList`; у блока-обёртки есть `attrs.id`, у `listItem` нет `attrs`, у его параграфа `id:null`. При склейке (сброс обёртки, перенос `listItem`-ов) ни id обёртки, ни вложенные id не создают коллизий. - ordered-фрагмент СОХРАНЯЕТ `start` (`"3. три"` → `start:3`); стиль нумерации `orderedList.attrs.type` по умолчанию `null` (обычный decimal). - task-список даёт `taskList`/`taskItem{checked}` — иная модель, чем `bulletList`/`listItem`; импортёр `bridgeTaskLists` делает `taskList` только когда ВСЕ пункты с чекбоксом, поэтому смешанных списков не порождает. - round-trip P1/P2 (#351) на слитом списке держится: номер пункта в дереве не хранится, сериализатор считает `start+index`, поэтому слитый `orderedList{start:S}` сериализуется в непрерывный `S, S+1, …` и реимпортируется в один список. Смежное: - #351 (закрыт) — round-trip инварианты пакета: P1 (семантический round-trip) и P2 (байтовый fixpoint со второго прохода). Не ослаблять; корпус контрпримеров — `packages/prosemirror-markdown/test/fixtures/counterexamples/`. - `ordered-list-start-normalization.test.ts` — нормализация вырожденного `start` (0/отриц./дробь → 1) на эмите; ортогональна склейке, конфликта нет. - #188 — заметки об инструментах (обратная связь по insertNode), действий не требует. ## Дизайн Ввести СКЛЕЙКУ НА ШВЕ ВСТАВКИ (list seam coalescing): после того как список-блок поставлен на место, если он оказывается непосредственно смежным с существующим списком-сиблингом ТОГО ЖЕ типа узла — влить пункты, а не оставлять два блока. Склейка строго локальна к шву вставки; глобальной нормализации документа НЕТ (иначе разрушим намеренно раздельные списки в других местах и P2 из #351). Общий хелпер (единственный источник правила склейки, R3): ```ts // Exactly the three list containers we coalesce. NOT `type.endsWith("List")`, // which also matches `footnotesList` (footnotes must never be structurally merged). function isCoalescibleList(n: any): boolean { return isObject(n) && (n.type === "bulletList" || n.type === "orderedList" || n.type === "taskList"); } // Two lists merge ONLY when node types are identical. For orderedList also require // a compatible numbering style: treat missing/null `attrs.type` as the default and // BLOCK the merge only when BOTH carry an explicit, differing `type`. function listsMergeable(a: any, b: any): boolean { if (!isCoalescibleList(a) || !isCoalescibleList(b)) return false; if (a.type !== b.type) return false; if (!Array.isArray(a.content) || !Array.isArray(b.content)) return false; if (a.type === "orderedList") { const ta = a.attrs?.type ?? null, tb = b.attrs?.type ?? null; if (ta != null && tb != null && ta !== tb) return false; } return true; } ``` Процедура склейки. Вставка размещается в родительском массиве `parent` (для `append` — `doc.content`; для `before`/`after` — `chain[len-2].node.content`, т.е. непосредственный родитель совпадения якоря, полученный через `findAnchorChain`), занимая индексы `[i, j)`. ЖЁСТКИЕ ОГРАНИЧЕНИЯ (обязательны — иначе фикс снова открывает дыры): - **Ровно два шва, по одному разу.** Склейка срабатывает ТОЛЬКО на левом шве (`parent[i-1]` ↔ `parent[i]`) и правом шве (`parent[j-1]` ↔ `parent[j]`), каждый максимум один раз. НЕ реализовывать как `while (соседи одного типа) merge` — такой цикл после сдвига индексов «съест» дальше стоящий НАМЕРЕННО раздельный список и сломает критерий 5(a). Пары, где ОБА блока существовали до вставки (вне шва), не склеиваются. - **Выбор выжившего — ПОЗИЦИОННЫЙ, никогда по id.** `reassignCollidingBlockIds` пере-минтит id обёртки вставляемого списка ДО сплайса, поэтому по id отличить вставленный список от настоящего нельзя. Выживший определяется исключительно диапазоном `[i,j)`: сосед ВНЕ диапазона — предсуществующий (survivor), граничный блок ВНУТРИ диапазона — вставленный. Выживший сохраняет свой блок `id` и list-уровневые атрибуты; пункты вставленного списка переносятся в выжившего (в НАЧАЛО, если выживший справа — случай `before`; в КОНЕЦ, если выживший слева — `append`/`after`), затем вставленная обёртка удаляется. Пере-минченный id обёртки таким образом всегда отбрасывается и в документ не попадает. - **Трёхсторонний случай** `[preA, insertedList, preB]` (вставка попала ровно между двумя предсуществующими списками одного типа): выживает ЛЕВЫЙ предсуществующий (`preA`); пункты вставленного и затем пункты `preB` переносятся в `preA`, `preB` удаляется. Блок `id` у `preB` НЕ сохраняется — редкий кейс (требует двух смежных однотипных списков вокруг точки вставки), задокументирован. - **Пустой вставляемый список** (возможно только на `node`-пути): если у вставленного списка ноль пунктов — склейку НЕ выполнять, оставить блок как вставлен (`inserted:true` остаётся правдивым). Владелец атрибутов: всегда предсуществующий список. Для `orderedList` это значит непрерывную нумерацию от его `start`; атрибуты вставленного списка (`start`/`type`) отбрасываются — осознанно: типичный интент вставки в список — продолжение, а не рестарт. Атрибуты `listItem`/`taskItem` (в т.ч. `checked`) переносятся вместе с пунктами. Где применяется: - `insertNodesRelative` (markdown-путь): `append` (левый шов с прежним хвостом), `before`/`after` (оба шва пробега). Пробег может содержать не только списки (напр. `- a\n\nтекст\n\n- b` → [bulletList, paragraph, bulletList]) — шов срабатывает только когда ГРАНИЧНЫЙ блок пробега сам список; внутренние блоки не трогаются. Ветки `before`/`after` перевести на `findAnchorChain` (сплайс в непосредственный родитель совпадения, `chain[len-2].content` по индексу `chain[len-1].index+offset`), чтобы склейка шла по фактическому родительскому массиву — это заодно корректно обрабатывает список, вложенный в callout / ячейку таблицы. Семантика first-match / top-level-only при этом сохраняется (проверено: идентична текущему `walkContent`). - `insertNodeRelative` (сырой `node`-путь): те же ветки для одиночного узла-списка, через тот же хелпер. Структурная табличная ветка не затрагивается. Оба пути используют ОДИН хелпер склейки, чтобы поведение markdown и `node` было идентичным. Семантика отказов (R4): склейка — чистая трансформация над клоном дерева, сетевых эффектов нет. Запускается ТОЛЬКО после успешного размещения (когда `inserted` уже true); если якорь не найден — вставки нет, склейки нет, документ не меняется. Идемпотентность для collab-ретрая: трансформа переисполняется на свежем `liveDoc`, склейка детерминирована поверх результата вставки того же прогона, отдельного состояния не хранит. ## Границы scope - НЕ трогаем сериализатор и `LIST_MARKER_SEPARATOR` — для двух реально раздельных соседних списков разделитель по-прежнему корректен. - НЕ вводим глобальную нормализацию «схлопнуть все соседние списки в документе» — только два шва активной вставки. - НЕ вводим opt-out «вставить список отдельным сиблингом» (YAGNI). Как следствие, вставить намеренно РАЗДЕЛЬНЫЙ ordered-список сразу после другого ordered-списка через insertNode будет нельзя — редкий кейс, приносится в жертву общему интенту «продолжить список». Понадобится — отдельным issue. - НЕ склеиваем `bulletList`+`taskList` (разные модели содержимого). - Пути `patchNode`/`updatePageMarkdown`/`updatePageJson` не затрагиваются. ## Критерии приёмки Юнит-уровень пакета (`packages/prosemirror-markdown/test/`, рядом с `node-ops-splice.test.ts` и `node-ops.test.ts`) + round-trip. 1. `insertNodesRelative([bulletList[1,2,3]], [bulletList[4]], {position:"append"})` → `doc.content` содержит РОВНО ОДИН `bulletList` [1,2,3,4] (`doc.content.length` === 1). Явный ассерт на длину, не на `verify`. 2. То же для `orderedList`: один `orderedList`, `start` исходного сохранён, пункты 1..4 непрерывно; НЕТ второго списка с `start:1/3`. 3. `after`/`before` с `anchorNodeId` на список того же типа → один слитый список; с `anchorText`, резолвящимся в список-блок → то же. 4. Оба шва: `doc=[listA, <point>, listB]` того же типа, вставка списка между ними → один список [listA-items, inserted-items, listB-items] в исходном порядке. 5. **Локальность (регресс-гард):** (a) документ с ДВУМЯ намеренно раздельными списками одного типа (реимпорт из markdown с `<!-- -->` между ними), НЕ смежными с точкой вставки, после несвязанной вставки в другом месте остаётся ДВУМЯ списками и по-прежнему эмитит `<!-- -->`; (b) вложенный родитель: вставка списка рядом со списком ВНУТРИ callout сливает пункты в массиве содержимого callout, верхний уровень не тронут. 6. `before` для `orderedList{start:5}` → слитый список сохраняет `start:5` (проверка позиционного выбора выжившего — выживает предсуществующий, не вставленный). 7. Разные типы НЕ склеиваются: `taskList` рядом с `bulletList` → два блока (и `<!-- -->` в сериализации — корректно). 8. `taskList`+`taskList` склейка: каждый `taskItem.checked` сохраняется после слияния и round-trip. 9. Смешанный пробег markdown (`- a\n\nтекст\n\n- b`) у якоря-списка: граничные списки склеиваются с соседями, параграф в середине — отдельный блок. 10. Путь `node` (`insertNodeRelative` с сырым `bulletList`) склеивается идентично markdown-пути. Пустой вставляемый список (`content:[]`) рядом с `bulletList` НЕ исчезает: остаётся отдельным блоком (или задокументированное поведение), `inserted:true`. 11. Сериализация слитого списка НЕ содержит `<!-- -->` между пунктами; round-trip P1/P2 (#351) зелёный. Golden-фикстура на `один/два/три + четыре` (было: два списка + `<!-- -->`; стало: один список). 12. Якорь не найден → документ не изменён, `inserted:false`. 13. E2E MCP (`packages/mcp/test-e2e.mjs` или `packages/mcp/test/mock/ markdown-patch-insert.test.mjs`): `insertNode append "- четыре"` к странице со списком → `getPageJson` показывает один `bulletList` с 4 пунктами; `getPage` (markdown) не содержит `<!-- -->`. ## Отказы и наблюдаемость - Новых сетевых/фоновых путей нет; склейка синхронна и чиста. Оператор ничего нового не видит. - `verify` (`DiffResult`/`VerifyReport`) отдаёт `summary.{inserted,deleted, blocksChanged}` и integrity-счётчики — ОТДЕЛЬНОГО поля «число верхнеуровневых блоков» там нет. Слияние отразится как `blocksChanged` + прибавка символов; поле ответа `blocks` остаётся числом ИМПОРТИРОВАННЫХ блоков (вход). Инвариант «число блоков не выросло» проверяется юнит-ассертом на `doc.content.length` (крит. 1), а не через `verify`. Audit trail не требуется (операция не деструктивная). ## Совместимость и миграции - Смена поведения: `insertNode` списком рядом с одноимённым списком теперь СКЛЕИВАЕТ вместо создания сиблинга. Это исправление бага; кода, полагавшегося на появление `<!-- -->`, не ожидается. Отметить в PR. - `orderedList`: непрерывная нумерация вместо рестарта с 1 — смена наблюдаемого вывода (в лучшую сторону), отметить. - ЯВНО: вставка ordered-списка с явным стилем нумерации `type` (напр. `"a"`, alpha) рядом с предсуществующим списком дефолтного стиля (`type:null`, decimal) НЕ блокируется — списки сольются, и пункты вставленного будут отрисованы стилем выжившего (decimal). Осознанная потеря явного атрибута вставляемого списка (согласуется с правилом «атрибуты вставленного отбрасываются»). Отметить в PR. - Миграций БД/схемы нет; блокировок горячих таблиц нет; правка чисто в трансформации дерева. Rollback — обычный откат PR. ## Решённые развилки - Насколько широко чинить → ВСЕ позиции (append/before/after); правка в общих splice-хелперах, `after`/`before` дают тот же баг → человек. - markdown-путь vs `node`-путь → ОБА (общий хелпер, единое поведение) → человек. - Одно маркерное семейство, разный тип (bullet↔task) → склеивать ТОЛЬКО идентичный тип узла → разные модели содержимого → человек. - Владелец атрибутов при слиянии → ПРЕДСУЩЕСТВУЮЩИЙ список (позиционно по диапазону `[i,j)`), не «левый»; иначе `before` затирал бы id/`start` существующего списка → дизайн (adversarial-разбор). - Область склейки → только два шва вставки, по одному разу, без итерации наружу → сохранить P2 (#351) и намеренно раздельные списки → дизайн. - ordered-стиль `type` → блокировать слияние лишь при двух ЯВНЫХ разных `type` (default `null` сливается) → не защищать частый decimal-кейс лишней строгостью → дизайн. ## Связи - Опирается на инварианты #351 (round-trip) — не ослаблять; переиспользовать round-trip/golden-инфраструктуру для критерия 11. - Контекст-фидбек: #188. --- 🤖 Составлено orchestrator-feature-designer (дизайн прошёл adversarial self-attack: 3 критика + повторная атака на интегрированный пакет).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#535