fix(prosemirror-markdown): insertNode дописывает пункт в существующий список вместо второго списка (#535) #537
Reference in New Issue
Block a user
Delete Branch "fix/535-list-seam-coalesce"
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?
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 переносятся в него по порядку.content.length > 0gate) —inserted:trueостаётся правдивым.isCoalescibleList— явный allowlistbulletList|orderedList|taskList(НЕendsWith("List")— иначе поймал быfootnotesList).bulletList+taskListне сливаются (разные модели).orderedListtype-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-го прохода).nodes-write.tsне тронут.tsc0.while→ краснеет РОВНО crit5a локальности; снятие gate пустого списка → краснеет РОВНО crit8) — тесты не-вакуумны, ловят именно заявленные инварианты. Вердикт: APPROVE, багов нет.Осознанные форки (не блокеры)
preBне сохраняется (редкий кейс — два смежных однотипных списка вокруг точки вставки).Checklist
LIST_MARKER_SEPARATORне тронуты🤖 Авто-ревью · #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.typegate → crit7 краснеет, right-first→left-first → crit4 краснеет).🔧 Do — потом ре-ревью
node-ops.ts:95-99. Веткаi===j-1 && leftMergeable && rightMergeableпроверяет пары left↔inserted и inserted↔right, но НЕ left↔right. Вставленный ordered-список с null/defaultattrs.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.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, чисто.
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>Готово к ре-ревью (
dd1fe905+ мерж develop515a1aaf). Обе находки закрыты и залочены тестами.Внутренний цикл: 1 фикс-проход, адверсариальная проверка всех комбинаций совместимости соседей + 2 хирургические мутации.
F1: fixed— трёхсторонняя транзитивная потеря numbering-стиля. Добавил&& listsMergeable(left, right)в трёхстороннее условиеcoalesceSeams. При провале guard'а (оба одиночных шва валидны, но left↔right несовместимы) — fall-through: обрабатываем ТОЛЬКО левый шов (keep-left, консистентно с выбором выжившего), правый список остаётся отдельным со своим стилем. Репро ревьюера[ol"a"[1], ol"i"[9]]+ insertol(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")) краснит РОВНО этот тест.📋 Проверка (комбинации соседей + прогоны)
Прослежены ВСЕ комбинации: 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-1guard).pm: 762/762 (crit1-8, taskItem/idempotency, #351 P1/P2 зелёные); mcp: 832/832. node_modules 0; scope —
node-ops.ts+ тест. Влил текущий develop.Ревью — 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])]+ insertol(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. Дерево после мутаций восстановлено (чисто).
База develop
515a1aaf, чисто. Готово к мержу.