feat(footnotes): author-inline footnotes + deterministic server canonicalization (#228) #232
Reference in New Issue
Block a user
Delete Branch "feat/228-inline-footnotes"
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?
Closes #228.
Сноски стали авторскими-инлайн: агент даёт только ГДЕ (anchor) + ЧТО (текст), а нумерация и список внизу выводятся детерминированно на сервере — у агента нет доступа к
footnotesList, рассинхрон невозможен. + дедуп по содержимому (одинаковый текст → один номер/определение, несколько ссылок).footnote-canonicalize.ts(editor-ext, pure) + зеркало вpackages/mcp(mcp намеренно развязан с editor-ext-барелем): нумерация по порядку первой ссылки, один definition на id в порядке ссылок, сироты дропаются, идемпотентно. Golden-тест: стабильные состояния live-плагина — байт-в-байт no-op канонизатора.insert_footnotetool (pageId/anchorText/text), дедуп поfootnoteContentKey, атомарно.Тесты: editor-ext 157, mcp 325 (вкл. e2e markdown-import ordering fix). tsc (server+client) 0. Deferred (Phase 4 по issue):
normalize_footnotesдля литералов в уже-сохранённом JSON, git-sync footnote support.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
🤖 Generated with Claude Code
Code review — author-inline footnotes + детерминированная серверная канонизация (#228)
Вердикт: Approve with comments (одобрить с замечаниями). База —
develop(merge-base904f7b4), 35 файлов, +5584/−72. Полный обзор по 8 аспектам (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture). Блокирующих находок нет, критических нет.Код продуман и тяжело документирован, инвариант сносок энфорсится во всех full-document write-путях; рефакторинг
insertMarkerAfter→insertNodesAfterAnchorпобайтово сохраняет старое поведение; канонизатор — строгий no-op для контента без сносок; закоммиченныйpackages/mcp/build/*.jsпобайтово совпадает со свежимtsc; счётчик тулов 38→39 корректен. Прежде чем мержить, стоит закрыть один узкий дефект (ниже) — он не блокирует, но это реальная регрессия контента.Must fix before merge
[warning][stability] Удаляйте «осиротевший»
footnoteDefinitionвнеfootnotesList, иначе rebuild его дублирует —packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts:157-161(и зеркалоpackages/mcp/src/lib/footnote-canonicalize.ts:~157).В шаге 7
stripFootnotesListsDeepудаляет только узлыfootnotesList, ноcollectDefinitionsсобирает определения рекурсивно на любой глубине. Если определение лежит «голым» вне списка (например, вложено вcallout), оригинал остаётся на месте, а копия попадает в пересобранный нижний список — определение дублируется (две ноды с однимid, идемпотентно, само не чинится). Воспроизведено эмпирически на собранном канонизаторе;PMNode.fromJSONтакой документ принимает. Достижимо только через raw-JSON write-пути (update_page_json/docmost_transform), где агент вручную авторитfootnoteDefinitionвне списка: схемаcallout—block+, аfootnoteDefinitionне входит в группуblock, поэтому обычный редактор/импорт/вставка такую форму не порождают. Предусловие надуманное → non-blocking, но это реальная порча контента, которую вносит именно это изменение.Fix: после
stripFootnotesListsDeep(out)в шаге 7 (в обеих копиях) дополнительно вырезать любые уцелевшиеfootnoteDefinitionна всех глубинах (компаньонstripFootnoteDefinitionsDeep), и добавить корпус-кейс с голым определением воcallout, чтобы запинить поведение в обоих зеркалах.[suggestion][simplification] Уберите недостижимую проверку top-level
footnoteReferenceвcanonicalizePastedFootnotes—apps/client/src/features/editor/extensions/markdown-clipboard.ts:184-190.Строка
if (node.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true;внутри top-levelforEachникогда не срабатывает: функция выходит раньше, еслиopenStart/openEnd !== 0, поэтому верхние дети slice — целые блоки, аfootnoteReference—inline, atom, ловится только последующимnode.descendants(...). Редундантная защитная строка, внесённая этим diff.Fix: удалить её, оставив скан
descendantsи top-level проверкуhasFootnotesList.Test coverage
Покрытие нового кода в целом очень хорошее:
canonicalizeFootnotes(порядок по ссылкам, first-wins дедуп, дроп сирот, идемпотентность, fast-path «уже канонично», strip-all при отсутствии ссылок, вложенные ссылки, размещение перед хвостовым пустым параграфом) закрыт общим golden-корпусом в обоих пакетах + golden-parity с live-плагином;insertInlineFootnoteзакрыт исчерпывающе (дедуп по контенту, новый id, anchor-not-found,forbidBlockTypes/codeBlock,skipSubtreeTypes/refusal, границаbeforeBlock); клиентская обёрткаinsertFootnote— через симmutatePage;markdownToProseMirrorCanonicalvsmarkdownToProseMirror(тела комментариев НЕ канонизируются) — обе ветки;page.servicereplace-канонизирует / append-prepend нет; паритет editor-ext↔mcp. Конкретные пробелы (все non-blocking):footnoteDefinitionвнеfootnotesList— та же ветка, что и warning выше; сейчас она и не тестируется, и ведёт себя неверно. Добавить кейс при починке.update_page_jsonиcopy_page_contentв MCP не покрыты (packages/mcp/src/client.ts:1354иcopyPageContent): однострочные идемпотентные вызовы уже хорошо протестированной чистой функции, но серверная сторона свои аналоги пинит фокус-тестами — стоит добавить симметричные на MCP-стороне через симmutatePage.FileImportTaskServiceне покрыт (apps/server/src/integrations/import/services/file-import-task.service.ts:496-511): тот жеcanonicalizeFootnotes-биндинг, что и вimport.service(он покрыт), но уFileImportTaskServiceспеки нет вовсе.Architecture & design
2. ~8 разбросанных вызовов
canonicalizeFootnotes, гарантируемых только комментарием-«enforcement rule» + различием replace-vs-fragment. Единый choke-point невозможен начисто: пути персистят через три несвязанных слоя (RESTPageService→collab gateway; два импорт-сервиса в обходPageServiceчерезpageRepo.insertPage; MCP-клиент через collab-WebSocketmutatePageContent, который зовётся из ~12 мест, большинство — fragment-операции, где канонизация запрещена). PR уже сделал правильную частичную консолидацию (markdownToProseMirrorCanonical). Остаточный риск — будущий новый full-doc путь, который забудет вызов (тихий сбой: сноски не по порядку).статус-кво + регресс-тест на каждую известную full-doc точку входа* (effort: s). Pros: превращает «помни правило» в проверяемое свойство. Cons: новый путь всё ещё полагается на чтение комментария.