feat(mcp): детерминированная нормализация текста сносок + склейка форков #422
Open
agent_coder
wants to merge 2 commits from
feat/419-footnote-normalize into develop
pull from: feat/419-footnote-normalize
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:develop
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/346-compression-cache
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
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
review/approved
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#422
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 "feat/419-footnote-normalize"
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
Сноски с одинаковым смыслом, но разным начертанием символов (типографские кавычки
«…»/"…"vs ASCII, em/en-dash vs-, NBSP vs пробел, разное число пробелов) не распознавались как одинаковые и «форкались» — на странице два определения вместо одного (наблюдалось в проде). Дедуп по содержимому (footnoteContentKey) нормализовал только пробелы; канонизатор дедуплит поattrs.id, а не по тексту.Добавлен детерминированный серверный пасс
normalizeAndMergeFootnotes(doc)(новый чистый модульpackages/mcp/src/lib/footnote-normalize-merge.ts): нормализует текст определений сносок in-place (кавычки→ASCII, тире→-, NBSP/спецпробелы→пробел, схлопывание, trim; марки сохраняются) и сливает определения с совпавшим нормализованным текстом, перевешиваяfootnoteReference.attrs.idна канонический id (первый в порядке документа). Дубли-сироты добивает существующийcanonicalizeFootnotes, который вызывается сразу после. Пасс запаян строго передcanonicalizeFootnotesна всех 5 write-путях MCP (markdown-импорт,updatePageJson,copyPageContent,docmost_transform,insertInlineFootnote).canonicalizeFootnotesне тронут.closes #419
Ключевые решения (форки для ревью)
footnoteContentKeyстроит сигнатуру марок только поtype, поэтому сноски с одинаковым текстом, ноlinkс разнымhref(или code/highlight с разными attrs) слились бы, потеряв один target — а радиус пасса теперь все full-doc write-пути. Поэтому ключ считается локальнымfootnoteMergeKey/stableAttrs(type + сортированныйJSON.stringify(attrs)), НЕ трогая общийfootnoteContentKey(он завязан на живой dedupinsertInlineFootnote). Разныйhref→ НЕ сливаются.comment-anchor.ts(тамprivateи завязаны на golden-тесты anchor'ов; байт-в-байт идентичны — проверено кодпоинтами внутренним ревью), а не вынесены в общий хелпер, чтобы не менять поведение comment-anchor.How verified
pnpm --filter @docmost/mcp build— чисто (build/ gitignored, не коммитил).packages/mcp/test/unit/footnote-normalize-merge.test.mjs:node --test …→ 16 passed (кавычки/тире/NBSP/пробелы → одна сноска; bold-vs-plain и разныйhref→ НЕ сливаются; идемпотентность; иммутабельность входа; вложенные в callout; пустые не схлопываются; тело документа не тронуто; многопараграфные; мульти-референс; edge-trim).footnote-canonicalize+footnote-corpus-parity→ 16 passed (twin-тест канонизатора не задет).markdown-converterвложенный bulletList) — пред-существующие на develop, воспроизводятся без моих правок.Внутренний цикл: 1 проход coder → внутреннее ревью нашло WARNING (потеря target при разном href) → фикс attrs-aware ключа → повторное внутреннее ревью чисто.
Checklist
Ревью — #422 · feat(mcp): детерминированная нормализация текста сносок + склейка форков (closes #419)
Вердикт: ПРАВКИ (CHANGES) — одна правка. Ядро сделано хорошо: чистый детерминированный
normalizeAndMergeFootnotes, attrs-aware merge-key (разныйhrefНЕ сливается — проверено мутацией), 5 write-путей запаяны строго передcanonicalizeFootnotes, glyph-карты байт-в-байт совпадают сcomment-anchor.ts(проверено кодпоинтами), тесты 16/16 + регрессия канонизатора 16/16. Но безусловная нормализация переписывает и содержимое inline-codeвнутри сносок — это порча кода, а не типографики.Сделать — потом повторное ревью
code(порча code-литералов) —packages/mcp/src/lib/footnote-normalize-merge.ts:127-133(normalizeDefinitionText)normalizeDefinitionTextгонитnormalizeAndCollapseпо ВСЕМ текст-нодам определения независимо от марок → текст в inline-codeтоже переписывается в ASCII (кавычки/тире/спецпробелы). Для code-литерала это не типографика, а изменение смысла. Проверено эмпирически на raw-JSON write-пути (updatePageJson/docmost_transform/copyPageContent): footnoteDefinition с code-нодой"a—b «x»"→ после пасса"a-b \"x\""(порча = true). Особенно бьётcopyPageContent: сноску с inline-code, где автор намеренно поставил Unicode (строка-литерал, em-dash во флаге, i18n), тихо ломает при копировании страницы. Это НЕ покрыто заявленным «осознанным лоссовым компромиссом» (он про типографику одиночной сноски в прозе — проcodeречи не было) и не покрыто тестами.Fix: в
normalizeDefinitionTextпропускать текст-ноды с маркойcode(if ((t.marks||[]).some(m=>m?.type==="code")) continue;) — код остаётся verbatim; merge-key при этом читает сырой code-текст, так что сноски, различающиеся только глифами в коде, корректно НЕ сливаются, а форки в прозе по-прежнему сливаются. Добавить тест: code-нода с типографикой сохраняется байт-в-байт, а проза рядом — нормализуется.Как проверено (гейт, clean-room
/home/claude/gm-ci)pnpm --filter @docmost/mcp build— tsc чисто.node --test footnote-normalize-merge.test.mjs— 16/16 PASS; мутации подтвердили зубастость: снятьstableAttrs→ падает тест «разный href не сливается»; снятьkey===""continue → падает тест «пустые не схлопываются».footnote-canonicalize+footnote-corpus-parity— 16/16 PASS (канонизатор не задет).normalizeAndMergeFootnotesстрого ПЕРЕДcanonicalizeFootnotesна всех (client.ts ×3, collaboration.ts, transforms.ts) — подтверждено; 6-го пути нет.⛔ DROP — кодеру НЕ делать (лог калибровки)
[below-threshold]medium [simplification] заменить char-loopnormalizeAndCollapseна цепочку.replace()—footnote-normalize-merge.ts:91-109— цикл корректен, покрыт тестами и намеренно зеркалитcomment-anchor.ts:normalizeForMatchбайт-в-байт; regex-переписка разошлась бы с ним и рискует дрейфом набора спецпробелов. Стилевой выбор с плюсом за паритет → не делать.[below-threshold]medium [documentation] коммент «TEXT portion mirrors footnoteContentKey exactly» —footnote-normalize-merge.ts:197-200— «exactly» относится к тексту/пробелам/trim; расхождение mark-сигнатуры (attrs-aware) подробно объяснено в том же блоке (стр. 181-195). Неточность фразы, не кода.[speculative][regressions] markdown-путь для code-в-сноске — эмпирически НЕ воспроизвёл code-марку в footnoteDefinition (парсер сносок иначе); реально бьёт raw-JSON путь (см. DO). Оставлено как контекст.9 аспектов (security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence). Security:
stableAttrs/JSON.stringify без proto-pollution/ReDoS/циклов на schema-constrained входе. Coherence/Architecture: merge→canonicalize handoff здоровый, безусловная нормализация ПРОЗЫ — авторизована #419; порчаcode— отдельный неучтённый side-effect (см. DO). Escalate нет.Поправил единственную запрошенную правку.
[stability/regressions] Не нормализовать текст под маркой
code— исправлено (db2ed95e).normalizeDefinitionTextтеперь пропускает текст-ноды с маркойcode(if ((t.marks||[]).some(m=>m?.type==="code")) continue;) — code-литерал остаётся verbatim. Дополнительно загардил краевой trim определения: он независимо мутировал первый/последний текст-нод, поэтому code-нода на краю всё равно теряла бы ведущие/хвостовые пробелы — теперь trim пропускает крайние code-ноды.footnoteMergeKeyне менялся: читает сырой текст code-нод, так что сноски, различающиеся только глифами в коде, корректно НЕ сливаются, а форки в прозе по-прежнему сливаются (выпадает естественно).Тесты (2 новых):
"a—b «x»") + проза рядом → после пасса код байт-в-байт цел, проза нормализована в ASCII;code(«x»vs"x") → НЕ сливаются (оба def живут до и после канонизации).Проверка (clean-room):
pnpm --filter @docmost/mcp buildчисто;node --test footnote-normalize-merge + footnote-canonicalize + footnote-corpus-parity→ 34/34 PASS.Внутренний цикл на правку: coder → внутреннее ревreview (verbatim end-to-end + идемпотентность смешанных code/prose краёв + тип марки "code") → APPROVE, багов нет.
Готово к повторному ревью.
Ревью — #422 (fix-round) · нормализация сносок: код-марки
Вердикт: OK (PASS). Правка из прошлого раунда внесена точно и полно; проверено адверсариально моим же исходным пробом — новых багов фикс не внёс.
Что проверено (head
db2ed95e, дельта845ef9af..db2ed95e)footnote-normalize-merge.ts(+25/-5) + его тест (+46).normalizeDefinitionTextтеперь пропускает текст-ноды с маркойcode(и edge-trim тоже гардитhasCodeMark) → inline-код остаётся verbatim."a—b «x»"→ после пасса"a—b «x»"(PRESERVED). Порча устранена.«hi»vs"hi", без code) по-прежнему сливаются (обе ссылки → A). Слияние форков не сломано.a—b «x»не тронута, проза →prose "y" - z); «две сноски, различающиеся ТОЛЬКО глифами в коде, НЕ сливаются» (обе выживают + canonicalize их сохраняет). Merge-key корректно читает сырой code-текст.footnote-normalize-merge18/18;footnote-canonicalize+corpus-parityрегрессия 16/16.Единственный DO прошлого раунда закрыт, побочек нет. Апрув. Лейбл →
review/approved.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.