fix(converter): block-escape + attribute contract + dedup mirrors (#493) #514
Reference in New Issue
Block a user
Delete Branch "feat/493-converter"
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
Итерация 8 эпика #497 — конвертер: block-escape, атрибутный contract-тест, дедуп зеркал. closes #493. Независима (база develop). 6 сущностных коммитов + 2 фикс-коммита по внутреннему ревью.
#/-/*/+/>/N./N)/fence/|/thematic-break) бэкслеш-экранируется при сериализации, закрывая класс потери данных (раньше маскировалось ZWSP-хаком). Экранируется КАЖДАЯ строка параграфа (не только первая) — иначе триггер после hardBreak (\n) переизвлекался в другой блок, а---терялся целиком через setext. Фаззер расширен, чтобы бить триггер и после hardBreak.spec.attrsкаждой editor-ext ноды/марки против vendored-mirror (класс, дропнувшийsubpages.recursive).warnings-sink или throwConverterLossErrorв strict (git-syncstabilizePageBody— strict, fault-isolated по-странично).---…---(front-matter strip — только серверный импорт; на agent-путиnormalizeAgentMarkdown= CRLF + GFM-сноски, no-op на канонике).regraftResolvedComments, скрыты от agent-read per #337).Коммит 7 (унификация zip .md import-path) осознанно отложен: PM→HTML→PM hop несёт attachment/cross-page-link/backlink/icon resolution; переписывать вслепую без теста на
.md-direct-with-attachments — риск data-integrity регрессии, ровно того, что эпик предотвращает. Рекомендую отдельным заходом с серверным import-сьютом + реальными zip-фикстурами.How verified
packages/prosemirror-markdown(vitest): 775 pass (генеративные round-trip P1/P2/P3 + пины + foreign-markdown). mcp unit: 691 pass. Целевые server importer-спеки (footnote-canonicalize ×3): 8 pass.---…---на agent-write); оба починены и mutation-verified (revert → генеративные P1/P2 + пины краснеют).Осознанные развилки
Checklist
🤖 Авто-ревью · #514 (конвертер: block-escape + attribute contract + дедуп, #493) — голова
31f51eaaВердикт: CHANGES — 1 critical потеря данных (тот же класс, что PR чинит, но с двумя незакрытыми формами) + CHANGELOG. Эскалаций нет (фикс однозначный). Работа сильная и почти вся сверена зелёной — но 9-й аспект нашёл дыру, которую 8 предыдущих LGTM и весь зелёный сьют пропустили, потому что фаззер и пины не бьют именно по
--/=.Прогнал полный 9-аспектный веер + гейт (весь зелёный: pm-markdown 775, git-sync, mcp 691/0, tsc 0). Security/regressions/test-coverage/conventions/architecture/coherence/simplification — LGTM и сверены лично: block-escape обратим для
---/#/>/N./fence/|(реально гонял round-trip), дедуп даёт РОВНО одну каноническую копию (stripInlineMarkdownбайт-в-байт = удалённой mcp-копии, mcp ре-экспортит из пакета, старый server-файл удалён — 0 dangling), front-matter граница не пересекается (agent-write никогда не идёт через REST-парсер), git-sync strict fault-isolated по-странично, regraft когерентен #337, контракт-тест реально краснеет на дропнутомsubpages.recursive.🔧 Do — потом ре-ревью
escapeLeadingBlockTriggerНЕ закрывает setext-underline--(ровно два дефиса) и одиночный=— тихая потеря текста.markdown-converter.ts:128-150. Bullet-арм^[-*+](?:\s|$)требует ОДИН маркер + пробел/EOL →--не матчит; thematic-арм^([-*_])(?:\s*\1){2,}требует 3+ →--не матчит; для=арма нет вовсе (==нейтрализуется отдельным inline-эскейпом, одиночный=— нет). Сверил вживую round-trip'ом через собранный конвертер:[text "a", hardBreak, text "--"]→ сериализуется"a \n--"→ парсится обратно как heading, текст ="a"→--ПОТЕРЯН.[text "a", hardBreak, text "="]→"a \n="→ heading →=ПОТЕРЯН.---→"a \n\---"(экранирован) → round-trip ✅;# b→\# b✅.Это ровно класс, ради которого сделан per-line-эскейп (его же коммент цитирует «setext-
---»), но--/=пропущены. Срабатывает на любом doc→md→doc (git-sync stabilize, agent-write) для документа, где строка-продолжение (после hardBreak) — ровно--или=. Сьют зелёный, потому чтоblockTriggerLeadRunArbэмитит только# - * + > 1. 1) |(без--/=), а пины бьют---/***/___, не--/=. Fix: добавь арм на setext-underline-строку, напр. после thematic-check:if (/^(?:-+|=+)\s*$/.test(line)) return "\\" + line;(сверил:\--/\=round-трипят как текст с сохранённым hardBreak). Расширь after-hardBreak-пин вgitmost-transcript-neutralization.test.tsна["a","--"]и["a","="], и добавь--/=-формы в генеративный корпус, чтобы класс был заперт. Проверь отсутствие двойного эскейпа во взаимодействии с inline-==→\=\=.[Unreleased]. Сиблинг-конвертер #347 несёт### Changed; конвенция enforced. Внешне видимо: git-sync/agent doc→md→doc больше не теряет параграф с ведущим блок-триггером (закрытый класс потери данных), front-matter strip теперь import-only, и новые converter-loss warnings/strict. Fix:### Fixed/### Changedпод[Unreleased], тег(#493).⛔ DROP — кодеру НЕ делать
[below-threshold][simplification] identity-.map(([hb,trigger])=>[hb,trigger])наhardBreakThenTriggerArb(text-arbitraries.ts:244-246) — мёртвый no-op; косметика.[below-threshold][simplification]getSchema(docmostExtensions)пересобирается на каждый вызовmirrorAttrs(schema-editor-ext-contract.test.ts:159-163) — хойстнуть на модуль; перф-в-тесте.[below-threshold][architecture]warnings-sink без прод-потребителя (только тест) — YAGNI, но опция симметрична strict, терпимо.9 аспектов. Гейт зелёный целиком:
@docmost/prosemirror-markdownvitest 775 pass (44 файла), git-sync vitest pass, mcp node:test 691/0, server tsc 0. База develop (three-dot отfe5bd159; живой base develop+wave-1, мёржится чисто). Миграций/node_modules нет. Дедуп-цель достигнута; commit-7 (zip .md) осознанно отложен — согласен (ортогонален, PM-hop не тронут).CRITICAL (потеря текста для setext
--/одиночного=) закрыт + CHANGELOG.Фикс: в
escapeLeadingBlockTriggerдобавлена setext-рука ПОСЛЕ тематической — целая строка^-+[ \t]*$или^=+[ \t]*$экранирует ведущий символ. Корень: реально не-нейтрализованными доходили только--(ровно два дефиса — один ловит bullet-рука через$, 3+ — тематическая) и одиночный=;==/===уже экранируются инлайн-escape'ом раньше, новая рука против них идемпотентна (строка приходит как\=\=, старт с\,^=+не матчит).Round-trip всех форм проверен вживую (serialize→parse):
--→a⏎\--→оба текста целы; так же=/==/===/====/---/----. Mid-content-/=(a - b,x = y,--flag) — байт-в-байт без бэкслэша (заякорено на всю строку). Пины--/----/=/====+ генеративныйhardBreakThenSetextArb(weight 2). Mutation-verify: ревёрт руки → пины И генеративное свойство краснеют (fast-check ужимает ровно до setext-кейса).Гейт:
packages/prosemirror-markdownvitest 775 зелёные; mcp 691+129; серверные importer/export 14. Дельта — 4 файла, 0 node_modules;case "text"/renderInlineChildrenне тронуты.Внутренний цикл: ре-ревью дельты подтвердил корректность/идемпотентность/отсутствие новой коррупции. Остальные пункты ревью (block-escape для
---/#/>/|/N., дедуп, front-matter-граница) были LGTM и не менялись.🤖 Ре-ревью · #514 (конвертер, #493) round 2 — голова
3a344626. Вердикт: PASS ✅Оба DO закрыты и сверены адверсариально:
CRITICAL (setext
--/одиночный=) — закрыт, проверил тем же эмпирическим round-trip'ом через собранный конвертер, что и находил:if (/^-+[ \t]*$/.test(line) || /^=+[ \t]*$/.test(line)) return "\\"+line;(вся строка — только дефисы ИЛИ только равно, заякорено, trailing spaces/tabs ок), с подробным комментом.[text "a", hardBreak, text "--"]→a\--→ параграф,--СОХРАНЁН (было: heading, потеря); то же=→a\=→параграф. Контроли---/# b— по-прежнему round-трипят.-/=;---(3+) ловится thematic-армом РАНЬШЕ → нет двойного эскейпа;==глушится inline-эскейпом раньше → сюда доходит только одиночный=. Всё как в комменте.["a","--"],["a","="],["a","----"],["a","===="]) + пины вgitmost-transcript-neutralization.test.ts— класс заперт.CHANGELOG — запись добавлена, точная.
Гейт зелёный целиком (шеринг-конвертер тронут → прогнал всех трёх потребителей):
@docmost/prosemirror-markdown775/775,@docmost/git-sync269/269, mcp node:test 691/0. Новое эскейпинг-поведение не уронило ни одного голдена/round-trip-теста.Хороший фикс — минимальный, точный, покрыт. Approve.
Независимое ревью P1-зоны (block-escape / round-trip / regraft), с прогоном тестов и mutation-проверками на ветке. Вердикт: мержить можно после одного обязательного действия.
Обязательно перед мержем
Конфликт (
mergeable:false) — толькоCHANGELOG.md, секция[Unreleased] → ### Fixed: обе стороны дописали буллет в одно место (PR — #493 block-escape; develop — #489 из волны 1). Код-конфликтов НЕТ (проверено реальнымgit merge developв worktree —collaboration.tsи прочее мержатся чисто). Резолв: сохранить оба буллета.Ключевое подтверждено (то, ради чего ревью затевалось)
# A a+ hardBreak +--— генеративный сьют сам ловит и триггер-после-hardBreak, и setext. Без этого 775 зелёных ничего бы не доказывали.not.toContain(ZWSP); двойного escape нет; код-блоки не задеты; no-op на канонике доказан тестом (byte-identical), не прозой; fault-isolation git-sync по-странично подтверждена; коммит 7 отложен ЦЕЛИКОМ (ни одной zip-строки); дедуп stripInlineMarkdown без мёртвой копии.Не блокирует — follow-up (по правилу эпика: ограничение = issue)
toYdoc(дефолтный excludes марки). Редко, зона только resolved (скрытых), но тихая потеря в P1-зоне; теста нет. Плюс наблюдаемость: дроп якоря при не-матче — без warning. Резолюция follow-up: потреблять сматченные диапазоны (N вхождений → N якорей по порядку) + warning в sink при дропе.page.service.ts:1307-1321,parseProsemirrorContent) всё ещё стрипает front-matter — тот же класс потери, что PR закрыл для MCP-агента; несимметрия agent-write (защищён) vs REST-write (нет). Не регрессия этого PR — в бэклог.markdown-converter-gaps.test.ts:433(«now closed») при spec 12, всё ещё утверждающем остаточную потерю; [NIT] golden registry-stamp продублирован литералами в двух тест-файлах (общая фикстура).После резолва CHANGELOG — можно в develop.
Влил develop (был конфликт после мержа волны-1 эпика #497 в develop). Merge-коммит
401d6211.Конфликт — только
CHANGELOG.md(классическая коллизия секции[Unreleased]): взял ОБА набора записей — develop-овские (#489) и здешнюю (#493), ничего не потеряно. Код (packages/mcp/src/lib/collaboration.ts) авто-смёржился без конфликта; ни один converter-файл (prosemirror-markdown) в конфликт не попал.Проверил сборку в правильном порядке зависимостей (важно — иначе ложный TS7006): свежий
@docmost/prosemirror-markdownbuild → exit 0;@docmost/mcpbuild против него → exit 0. (Если собирать mcp против ещё не пересобранного pm-markdown,mcp/src/lib/text-normalize.tsдаёт implicit-any на импортеstripWrappersAndLinks— потому что этот PR его как раз ЭКСПОРТИРУЕТ; на чистом CI, где pm-markdown собирается первым, всё зелено. Это тот же stale-sibling-артефакт, что ты отмечал в ревью, а не регресс.)Правка чисто мержевая (CHANGELOG + авто-мерж develop), кода PR не трогал → флажу
review/needsдля быстрого ре-подтверждения резолюции конфликта. Объективка прежней головы3a344626(build 0, pm-markdown 777/777, git-sync 269/269, mcp 820/0) в силе.Апдейт: пока резолвил, в develop влился ещё #512 (#496 audit) — та же CHANGELOG-коллизия секции
[Unreleased]. Влил снова (mergeb041944a), взял ОБА набора записей (#496 + #493). Теперь ветка содержит текущий develop, конфликтов нет.review/needsдержится.🤖 Ре-подтверждение · #514 (конвертер block-escape, #493) — после влития develop (+#512 audit CHANGELOG-коллизия разрешена), голова
b041944a. Вердикт: PASS ✅Голова сменилась (интеграция develop, не новый код). Пере-верифицировал крюкс + гейт:
--/одиночный=) ПЕРЕЖИЛ мерж — арм/^-+[ \t]*$/ || /^=+[ \t]*$/на месте; перегонял свой эмпирический round-trip через собранный конвертер на новой голове:a--→a\--→параграф (текст цел), то же=; контроли---/# b— ок.@docmost/prosemirror-markdown775/775,@docmost/git-sync269/269, mcp node:test 840/0.Мерж-резолюция (включая CHANGELOG-коллизию с #512) чистая, крюкс цел. Approve (как на round 1).