fix(prosemirror-markdown): insertNode со списком плодит сиблинг + <!-- --> вместо дописывания пункта — склейка на шве вставки
#535
Open
opened 2026-07-12 02:23:50 +03:00 by agent_vscode
·
0 comments
No Branch/Tag Specified
main
fix/503-hardbreak-canon
docs/471-breadcrumb-permission
fix/534-mcp-spaceid
feat/542-resolve-undo
feat/481-version-coherence
feat/492-incremental-render
feat/529-search-lexical
fix/535-list-seam-coalesce
develop
fix/520-recovery-escalation
fix/525-insert-unloaded
fix/522-internal-links
fix/507-drawio-cyrillic
fix/505-drawio-strip-comments
fix/541-reconnect-timeout
feat/501-mcp-apikey-auth
feat/493-converter
feat/370-page-versioning
fix/515-code-emphasis
feat/506-apikeys-ui
fix/523-tree-dnd
fix/517-toasts
feat/495-tails
feat/494-mcp-registry-guards
feat/496-suggestions-audit
integ/497-wave1
feat/488-client-fsm
ci/476-publish-gates
feat/489-tool-resilience
feat/487-terminal-lifecycle
fix/486-iter1-prod-fires
refactor/347-client-md-paste
perf/479-getpage-cache
refactor/449-write-invariants
refactor/450-split-client
feat/443-get-page-context
feat/443-get-tree
feat/443-search
docs/415-lossy-descriptions
feat/413-markdown-default
refactor/412-camelcase-tool-names
feat/425-drawio-graph
fix/464-diffdocs-cpu-guard
refactor/411-update-page-markdown
feat/424-drawio-rules
fix/409-invalid-node-validation
refactor/448-generate-inventory
refactor/445-execute-mapping
refactor/446-derive-client-types
fix/447-registry-stamp-ci
fix/444-agent-loop-guards
fix/396-sendnow-detached-run
fix/452-immutable-cache-control
docs/footnote-authoring-comment-cleanup
feat/437-error-diagnostics
fix/442-cursor-pagination
feat/419-footnote-normalize
perf/399-comment-resolve-async
perf/435-collab-token-cache
perf/344-background-rerenders
perf/348-backend-lowhanging
feat/423-drawio-crud
perf/401-collab-hotpath
feat/430-live-reconnect
perf/400-collab-session
refactor/414-dedup-node-ops
feat/417-new-comments-signal
feat/408-createcomment-hints
feat/407-persist-tool-errors
feat/420-footnote-render
dummy-review/mcp-hang-fix
dummy-review/mcp-hang-base
feat/381-resumable-sse-pr1
feat/git-sync-2
docs/agents-workspace-build-order
feature/offline-sync
perf/342-code-splitting
perf/346-compression-cache
feat/196-multi-cursor
perf/343-typing-latency
docs/how-to-test
fix/ai-sdk-partial-output-oom
test/351-generative-converter
feat/371-roles-catalog
refactor/345-server-converter
refactor/294-spec-registry-cont
fix/363-migration-order
fix/e2e-callout-and-gate-build
fix/docker-re2-toolchain
feat/git-sync
fix/media-roundtrip-stability
fix/340-comment-panel-perf
fix/332-deferred-tools
fix/329-ephemeral-suggestions
fix/330-search-in-page
fix/328-resolved-anchor-spam
fix/331-intraline-diff
fix/324-coverage-gate
fix/325-mobile-390
feat/293-A-git-sync-package
feat/300-avatar-oklch
fix/321-banner-mobile
feat/300-avatar-colors
feat/315-comment-suggestions
feat/scroll-restore-stable-wait
feat/300-agent-avatar-stack
feat/300-avatar-polish
refactor/294-tool-spec-registry
feat/scroll-restore-ux
fix/responsive-tablet-sidebar
feature/ai-chat-page-change-observability
image-inline-center
fix/283-short-remap-title
fix/283-slash-layout
image-inline-row
feat/276-ai-chat-dock
fix/269-table-menu-refocus
docs/dev-stand-guide
feat/266-scroll-position
fix/260-collab-docname-slugid
test/244-phase2-tail
fix/262-reindex-progress-realtime
fix/258-changelog-compare-links
fix/244-dataloss-bugs
feat/246-spoiler
feat/221-image-captions
test/244-part-b
feat/251-intentional-clear
fix/embeddings-reindex-progress
refactor/193-tool-spec-registry
fix/255-ws-redis-adapter-leak
fix/252-e2e-open-handles
feat/229-catalog-yaml
feat/243-blob-sandbox
feat/228-inline-footnotes
fix/qa-ui-bugs-216-218
feature/agent-roles-catalog
fix/share-alias-rename
fix/ai-chat-empty-render
feat/191-chat-doc-binding
feat/201-temporary-notes
feat/198-interrupt-agent
feat/ai-chat-full-history
feat/199-ai-generate-title
feat/205-share-aliases
batch/issues-189-187-170
feat/170-mcp-test-button
feat/189-context-badge
feat/198-interrupt-agent-send-now
fix/issues-190-159
fix/ai-chat-new-chat-during-stream
fix/ai-chat-stream-perf
batch/issues-2026-06-25
feat/ai-chat-persistent-history
fix/ai-chat-copy-chat-wysiwyg
fix/ai-stream-reset-resilience
fix/ai-stream-undici-timeout
fix/footnote-review-1227-followup
fix/ai-chat-token-counter-realtime
docs/manual-qa-test-plan
v0.94.1
v0.94.0
v0.93.0
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#535
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема / Цель
insertNodeс markdown-списком не дописывает пункт(ы) в существующий список, асоздаёт ОТДЕЛЬНЫЙ второй список-сиблинг на том же уровне. Пример:
- один\n- два\n- три→doc.content: [ bulletList[один,два,три] ].insertNode(position:"append", markdown:"- четыре").doc.content: [ bulletList[один,два,три], bulletList[четыре] ]— двасписка. В экспортном markdown между ними появляется разделитель
<!-- -->(егонамеренно эмитит сериализатор
LIST_MARKER_SEPARATOR, чтобы два соседних<ul>не слились при реимпорте). Для
orderedListхуже: второй список получает своюнумерацию с 1.
Баг НЕ специфичен для
append:position:"after"/"before"рядом со спискомвоспроизводят тот же артефакт — markdown-фрагмент
- xвсегда импортируется какцелый блок
bulletListи сплайсится соседним блоком (проверено на собранном пакете).Последствие для агентов: 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), но разные типы узла.Установленные факты (проверено на собранном пакете):
- xимпортируется как целыйbulletList; у блока-обёртки естьattrs.id, уlistItemнетattrs, у его параграфаid:null. При склейке(сброс обёртки, перенос
listItem-ов) ни id обёртки, ни вложенные id не создаютколлизий.
start("3. три"→start:3); стиль нумерацииorderedList.attrs.typeпо умолчаниюnull(обычный decimal).taskList/taskItem{checked}— иная модель, чемbulletList/listItem; импортёрbridgeTaskListsделаетtaskListтолько когдаВСЕ пункты с чекбоксом, поэтому смешанных списков не порождает.
хранится, сериализатор считает
start+index, поэтому слитыйorderedList{start:S}сериализуется в непрерывный
S, S+1, …и реимпортируется в один список.Смежное:
(байтовый fixpoint со второго прохода). Не ослаблять; корпус контрпримеров —
packages/prosemirror-markdown/test/fixtures/counterexamples/.ordered-list-start-normalization.test.ts— нормализация вырожденногоstart(0/отриц./дробь → 1) на эмите; ортогональна склейке, конфликта нет.
Дизайн
Ввести СКЛЕЙКУ НА ШВЕ ВСТАВКИ (list seam coalescing): после того как список-блок
поставлен на место, если он оказывается непосредственно смежным с существующим
списком-сиблингом ТОГО ЖЕ типа узла — влить пункты, а не оставлять два блока. Склейка
строго локальна к шву вставки; глобальной нормализации документа НЕТ (иначе разрушим
намеренно раздельные списки в других местах и P2 из #351).
Общий хелпер (единственный источник правила склейки, R3):
Процедура склейки. Вставка размещается в родительском массиве
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). Пары, где ОБА блока существовали до вставки (вне
шва), не склеиваются.
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— для двух реально раздельныхсоседних списков разделитель по-прежнему корректен.
только два шва активной вставки.
вставить намеренно РАЗДЕЛЬНЫЙ ordered-список сразу после другого ordered-списка
через insertNode будет нельзя — редкий кейс, приносится в жертву общему интенту
«продолжить список». Понадобится — отдельным issue.
bulletList+taskList(разные модели содержимого).patchNode/updatePageMarkdown/updatePageJsonне затрагиваются.Критерии приёмки
Юнит-уровень пакета (
packages/prosemirror-markdown/test/, рядом сnode-ops-splice.test.tsиnode-ops.test.ts) + round-trip.insertNodesRelative([bulletList[1,2,3]], [bulletList[4]], {position:"append"})→
doc.contentсодержит РОВНО ОДИНbulletList[1,2,3,4] (doc.content.length=== 1). Явный ассерт на длину, не на
verify.orderedList: одинorderedList,startисходного сохранён, пункты1..4 непрерывно; НЕТ второго списка с
start:1/3.after/beforeсanchorNodeIdна список того же типа → один слитый список; сanchorText, резолвящимся в список-блок → то же.doc=[listA, <point>, listB]того же типа, вставка списка между ними →один список [listA-items, inserted-items, listB-items] в исходном порядке.
(a) документ с ДВУМЯ намеренно раздельными списками одного типа (реимпорт из
markdown с
<!-- -->между ними), НЕ смежными с точкой вставки, посленесвязанной вставки в другом месте остаётся ДВУМЯ списками и по-прежнему эмитит
<!-- -->;(b) вложенный родитель: вставка списка рядом со списком ВНУТРИ callout сливает
пункты в массиве содержимого callout, верхний уровень не тронут.
beforeдляorderedList{start:5}→ слитый список сохраняетstart:5(проверкапозиционного выбора выжившего — выживает предсуществующий, не вставленный).
taskListрядом сbulletList→ два блока (и<!-- -->в сериализации — корректно).taskList+taskListсклейка: каждыйtaskItem.checkedсохраняется послеслияния и round-trip.
- a\n\nтекст\n\n- b) у якоря-списка: граничные спискисклеиваются с соседями, параграф в середине — отдельный блок.
node(insertNodeRelativeс сырымbulletList) склеивается идентичноmarkdown-пути. Пустой вставляемый список (
content:[]) рядом сbulletListНЕисчезает: остаётся отдельным блоком (или задокументированное поведение),
inserted:true.<!-- -->между пунктами; round-tripP1/P2 (#351) зелёный. Golden-фикстура на
один/два/три + четыре(было: двасписка +
<!-- -->; стало: один список).inserted:false.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 — смена наблюдаемоговывода (в лучшую сторону), отметить.
type(напр."a", alpha)рядом с предсуществующим списком дефолтного стиля (
type:null, decimal) НЕблокируется — списки сольются, и пункты вставленного будут отрисованы стилем
выжившего (decimal). Осознанная потеря явного атрибута вставляемого списка
(согласуется с правилом «атрибуты вставленного отбрасываются»). Отметить в PR.
дерева. Rollback — обычный откат PR.
Решённые развилки
splice-хелперах,
after/beforeдают тот же баг → человек.node-путь → ОБА (общий хелпер, единое поведение) → человек.тип узла → разные модели содержимого → человек.
[i,j)), не «левый»; иначеbeforeзатирал бы id/startсуществующего списка →дизайн (adversarial-разбор).
сохранить P2 (#351) и намеренно раздельные списки → дизайн.
type→ блокировать слияние лишь при двух ЯВНЫХ разныхtype(default
nullсливается) → не защищать частый decimal-кейс лишней строгостью →дизайн.
Связи
round-trip/golden-инфраструктуру для критерия 11.
🤖 Составлено orchestrator-feature-designer (дизайн прошёл adversarial self-attack: 3 критика + повторная атака на интегрированный пакет).