e6171a1810
Правки по внутреннему ревью #413. 1. Orphan-сноска (нарушение «no second canon»). mergeFootnoteDefinitions раньше делал ранний return при пустых definitions, пропуская canonicalizeFootnotes. Если markdown-фрагмент БЕЗ сносок заменял/удалял блок, бывший последним referrer'ом существующей сноски, её определение оставалось orphan в хвостовом списке (полный ре-импорт его бы убрал). Теперь fast-path (возврат doc по ссылке без clone) только когда defs.length===0 И !hasFootnoteArtifacts(doc); иначе клон → append (no-op при пустых) → normalizeAndMergeFootnotes → canonicalizeFootnotes, как при полном импорте. Новый предикат hasFootnoteArtifacts обходит дерево на любой footnotesList/footnoteReference (существующие walk/isObject). Идемпотентно: несвязанный plain-патч на странице со сносками не трогает их топологию (canonicalizeFootnotes шаг 6 возвращает как есть). 2. Block-id disjoint от страницы. Новый экспорт reassignCollidingBlockIds (liveDoc, blocks, skipIndex?) в prosemirror-markdown/node-ops (переиспользует collectIds/makeFreshId) + barrel. Зовётся перед сплайсами в client.ts: patch — (liveDoc, threaded, 0) (skip 0 = блок, унаследовавший id цели); insert — (liveDoc, blocks) без skip. used-аккумулятор ловит и коллизию со страницей, и внутрифрагментную. freshBlockId/freshId без изменений. Тесты (+6, markdown-patch-insert): orphan-repro (0 defs/0 list/0 refs + docsCanonicallyEqual полному импорту), fast-path (footnote-free patch не трогает топологию, соседи byte-identical), insert канонизирует при пустых defs со страничной сноской, уникальность top-level block-id для patch 1→N и insert N. mcp node --test 702/702, pmd vitest 736, tsc чисто. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>