fix(converter): стабильность round-trip image/медиа — «» ≡ absent (класс defaults-instability) #350
Reference in New Issue
Block a user
Delete Branch "fix/media-roundtrip-stability"
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
Чиню класс нестабильности round-trip конвертера «пустая строка ≡ отсутствие» в пакете
@docmost/prosemirror-markdown(источник семейства GS-EDIT-REVERT: false-diff → churn → перезапись живых правок).Корень: изображение, сохранённое БЕЗ
alt, на каждом round-trip получало фантомныйalt: ""—markedрендериткак<img alt="">, а parseHTML схемы (getAttribute("alt")) материализует пустую строку там, где атрибута не было. У ai-chat/git-sync на каноне это живая экспозиция: касание агентом страницы с картинкой мутирует хранимый JSON (absent → "").Фикс — на parse-стороне («» ≡ absent), чтобы идемпотентным стал СЫРОЙ round-trip (история/хранимый JSON диффятся сырыми), а не только канонический.
image.alt/title→getAttribute(...) || null+ defense-in-depth|| nullпо всему рискованному классу (video aria-label, drawio/excalidraw title+alt, pdf name, attachment name+mime), как уже сделано дляimage.caption.align НЕ трогаю — он round-trip'ится корректно (center через schema default "center", left/right через
<!--img {…}-->). Диагнозы «align не несётся»/«center теряется» оказались ложными — единственная реальная сырая нестабильность былаimage.alt.Матрица (
test/roundtrip-stability.helper.ts) — переиспользуемый нодо-агностичный хелпер: нода + спека атрибутов → комбинации дефолт/недефолт → ассерт байт-стабильности СЫРОГО И канонического round-trip (числовая width/height→string кодирована как явная разрешённая нормализация). Прогнан по image + вся медиа-семья.Зарождающееся порождающее тестирование по схеме — живёт в пакете как общий тест-хелпер.
How verified
pnpm --filter @docmost/prosemirror-markdown test→ 33 файла / 672 passed.alt: null(было""),docsCanonicallyEqual = true; реальныйalt: "a real description"иalign: leftвыживают дословно.image.alt: absent → ""); медиа-семья была стабильна и до фикса.Checklist
Известное ограничение (НЕ этот класс, не трогаю): матрица заодно подсветила
embed.width/height/provider— дефолты не вKNOWN_DEFAULTScanonicalize, расходятся только под канонизацией (сырой контур стабилен). Это документированное ограничение canonicalize.ts, задача запрещала его трогать — атрибуты в спеке заданы на реальных дефолтах.A stored image authored without `alt` gained a phantom `alt: ""` on every round-trip (`markdownToProseMirror(convertProseMirrorToMarkdown(doc))`): `marked` renders `` as `<img alt="">`, and the stock tiptap Image `alt` parseHTML (`getAttribute("alt")`) materialized the empty string where the original had no attribute. That false diff is a real GS-EDIT-REVERT churn source — an agent / git-sync touch of a page with an image mutates the stored JSON (`absent -> ""`), producing phantom diffs that can overwrite live edits. Fix is PARSE-SIDE ("" ≡ absent), so the RAW round-trip is idempotent — not only the canonical form (history / stored JSON diff on the raw shape; masking it only in canonicalize would leave that noise). `image.alt`/`title` parseHTML now coerce `getAttribute(...) || null`, plus defense-in-depth `|| null` across the at-risk empty-string class (video aria-label, drawio/excalidraw title+alt, pdf name, attachment name+mime) matching the existing `image.caption || null` precedent. NOTE — image `align` is NOT changed: it round-trips correctly (center via the schema default "center", left/right via the `<!--img {...}-->` comment). Its `toBeUndefined()` in the git-sync gate is canonical-form normalization, not a loss. Intentional divergence from editor-ext: editor-ext's literal `alt` parseHTML returns "" verbatim, but this coercion CONVERGES on editor-ext's real STORED shape (an image inserted without alt has no `alt` attribute -> re-parses absent, never ""), so the round-trip is idempotent and matches real documents. Adds a reusable, node-agnostic round-trip-stability matrix helper (test/roundtrip-stability.helper.ts) — given a node + attr spec it enumerates default/non-default combos and asserts byte-stability of BOTH the raw and the canonical round-trip (the documented numeric width/height→string coercion encoded as an explicit allowed normalization) — driven over image + the whole media family (video/audio/pdf/attachment/embed/drawio/excalidraw). The only raw empty-string instability it found was image.alt; the family was already stable. Gate: package suite 33 files / 672 tests passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>review/needs. Fixtures-first: матрица-хелпер сначала подсветила нестабильность (image.alt: absent → ""), потом фикс, потом зелено.Ключевая проверка: воспроизвёл ДО фикса (три ложных диагноза — «align не несётся» → «center не восстанавливается» → на самом деле
alt; выжил только эмпирический), чинил ровно найденное. align НЕ трогал (корректен). Фикс parse-side, чтобы идемпотентным стал СЫРОЙ round-trip, не только канонический.Матрица — переиспользуемый нодо-агностичный хелпер (image + вся медиа-семья), останется в пакете как класс-замок.
Подход и скоуп одобряю (parse-side — верная сторона, матрица-замок — то, что нужно). Одно дополнение до ревью — третье значение класса:
Матрица кроет «дефолт + недефолт», но у строковых атрибутов есть вырожденное третье состояние — явно сохранённая пустая строка. Реальный путь к ней: юзер вписал alt в редакторе и стёр — Tiptap
updateAttributes({alt: ""})кладёт в хранимый JSON литеральныйalt: ""(это НЕ «картинка без alt не имеет атрибута» — тот аргумент верен только для никогда-не-заполнявшихся).После этого фикса такой документ на первом round-trip конвергирует
"" → null— разовый дифф, дальше стабильно. Это правильное поведение (нормализация к канону), но:alt: ""→ первый round-tripalt: null→ второй round-trip байт-стабилен (ассерт именно на идемпотентность со второго прохода). Иначе кейс живёт непроверенным.То же самое механически касается остальных строковых атрибутов из defense-in-depth списка (title, name, mime, aria-label) — матрица с третьим состоянием закроет их скопом.
Ревью — #350 (fix(converter): round-trip стабильность image/медиа «» ≡ absent), round 1. Вердикт: CHANGES
Сам фикс корректен (веер 9 аспектов + мои проверки сошлись): реальный баг —
markedрендериткак<img alt="">, иparseHTMLсхемы материализуетalt: ""на картинке, сохранённой БЕЗ alt → ложный дифф против editor-stored формы (alt ОТСУТСТВУЕТ, не "") → churn (GS-EDIT-REVERT), перезаписывающий живые правки. Parse-sidegetAttribute("alt") || null— правильный слой (git-sync диффит ре-парснутый JSON; сверено, чтоmarkdownToProseMirror→generateJSON(docmostExtensions)реально применяет override).embed.providerкорректно ИСКЛЮЧЁН (дефолт"", не null). Безопасность/архитектура/конвенции чисто.НО объективка НЕ надёжно зелёная и покрытие переоценено. Открыто 3 (нет критичных, нет эскалаций):
image.alt; остальные 7 коэрций (video/attachment/pdf/diagram) вакуумны (сериализаторы опускают falsy-атрибуты →|| nullне срабатывает через экспорт-пайп), а докстринг заявляет «one matrix guards the shared class».pnpm -r test): картинка иногда ре-материализуетalt: "". Корень — процесс-глобальныйglobal.documentвmarkdown-to-prosemirror.ts:250. Прод безопасен (глобал ставится один раз при загрузке модуля,generateJSONсинхронный), но CI будет краснеть недетерминированно.|| undefined», а код использует|| null(3 ревьюера independently отметили).Объективка (мой прогон, голова
2ce67270, CI-условия): frozen install 0; editor-ext+prosemirror-markdown build 0; prosemirror-markdown vitest 672 (в т.ч. новый stability) — но недетерминированно (F2, поймал 1 фейл на первом mixed-прогоне, затем 8/8 изолированно и 11/11 combo ×3); consumers зелёные — mcp roundtrip/media node --test 67/0, git-sync vitest 268, server tsc 0. Ни один существующий тест/консьюмер не ассертил баговое""(регрессий нет), контракт-тесты схемы не затронуты (меняется поведение parseHTML, не набор атрибутов).📋 Полный отчёт (F1–F3, DROP, что сверено)
Do — почини, потом ставь
review/needsF1 [test-coverage · warning] Сделай матрицу стабильности честной — 7/8 строк вакуумны —
packages/prosemirror-markdown/test/roundtrip-stability.test.ts(докстринг + матрица).Эмпирически (откат
docmost-schema.tsк базе + прогон): падает ТОЛЬКОimage.alt;video/audio/pdf/attachment/drawio/excalidraw(и дажеimage.title) проходят С ОТКАЧЕННЫМ фиксом — их коэрции недостижимы через экспорт-пайп (сериализаторы вmedia-html.tsопускают falsy-атрибуты, поэтому на реимпортеgetAttribute("aria-label"|"data-name"|…)возвращаетnull, а не"", и|| nullНЕ срабатывает). Т.е. эти 7 хунков — defence-in-depth без реально упражняющего теста, а докстринг «The image + media family … one matrix guards the shared class» это переоценивает.Fix (на выбор): (a) добавь parse-level строки, скармливающие парсеру HTML с ПУСТЫМ атрибутом напрямую (
<img aria-label="" …>,<a data-attachment-name="" …>,<img data-title="" data-alt="" …>) и ассертящие дефолт на реимпорте — тогда 7 коэрций станут невакуумными; ЛИБО (b) понизь докстринг:image.alt— единственная строка, воспроизводящая баг, остальные — forward-looking guards.F2 [stability/test-reliability · warning] Устрани флак нового gate-теста под параллельным сьютом —
roundtrip-stability.test.ts+ корень вmarkdown-to-prosemirror.ts:248-253.Тест недетерминированно падает ~1/14 под полным
pnpm -r test(то, что гоняет CI):RAW image.alt: absent -> "" (expected null)— ровно баг, который PR чинит, воспроизводится под гонкой. Корень:global.window/document/Elementприсваиваются на уровне МОДУЛЯ (один раз), но в тест-окружении несколько файлов реинициализируют модуль, и под параллельным исполнением файлов инициализация одного клобберитglobal.document, пока async-конвертация другого висит наawait(до синхронногоgenerateJSON). Новый тест — единственный, кто ассертит точное различие""/null, поэтому именно он ловит хрупкость (прочие 664 её толерируют). Прод я оценил как безопасный (глобал ставится один раз при загрузке модуля, не пер-вызов;generateJSONсинхронный → живые конкурентные конвертации не interleave-корраптят parse) — но gate-тест обязан быть детерминированным, иначе CI/луп краснеет случайно.Fix: сделай конвертер независимым от процесс-глобального документа для этого пути (свой JSDOM/
documentна вызов или выделенный не-глобальный документ), ЛИБО пин этого тест-файла на изолированный/непараллельный прогон. Заодно закрывает латентную хрупкость модульногоglobal.*-мутирования, которую вскрыл этот тест.F3 [documentation · warning-low] Почини самопротиворечивый комментарий в фикс-хунке —
packages/prosemirror-markdown/src/lib/docmost-schema.ts:~1519.Комментарий: «A real alt survives verbatim (
|| undefinedkeeps the truthy value)», а код (и предыдущее предложение того же комментария — «back to the attr's default (null)») использует|| null. Функционально безвредно, но вводит в заблуждение (3 ревьюера independently споткнулись). Fix:|| undefined→|| null.⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]low[simplification] Мёртвое полеmd: stringнаComboResult(roundtrip-stability.helper.ts:93объявлено,:201/211заполнено, нигде не читается). Тривиальная чистка тест-хелпера. Автор вправе оставить (или вывести вformatReportдля дебага) — DROP.[below-threshold]low[conventions] Имяroundtrip-stability.helper.tsvs сиблинг-идиома*-helpers.ts(roundtrip-helpers.ts). Защитимо (.helper.tsлегко читается как пара к.test.ts), корректно исключено vitest-глобом — DROP.[speculative]low[test-coverage] RAW-оракул сверяет реимпорт со схемным дефолтом (alt: null), а не с реальным editor-stored фикстур-JSON; теоретический key-present-vs-omitted churn мог бы проскользнуть. Низкая уверенность (tiptap материализует дефолты с обеих сторон) — DROP.Сверено (9 аспектов + мои проверки на голове
2ce67270): фикс покрывает КЛАСС (каждый коэрснутый атрибут имеетdefault: null;captionуже нёс|| nullдо PR;embed.providerисключён верно); слой parse-side корректен (markdownToProseMirror→generateJSON(docmostExtensions)); renderHTML не тронут, живое редактирование не затронуто;image.altпокрыт НЕвакуумно (RAW+CANONICAL); регрессий у консьюмеров нет; расхождение с editor-ext (literalgetAttributevs stored-shape) корректно и задокументировано;alignвне этого класса (дефолт"center", round-trip'ится).Добавил третье состояние класса (коммит
c192f2a2),review/needs.Третье состояние — явно сохранённая пустая строка нормализуется один раз, дальше стабильна. Помимо «атрибут отсутствует» и «атрибут с реальным значением», у строкового атрибута класса (image/drawio
alt+title, videoaltчерезaria-label, pdf/attachmentname, attachmentmime) есть третье, вырожденное состояние: литеральный""— когда юзер вписал значение в редакторе и стёр, Tiptap кладётattr: ""(это НЕ «атрибут никогда не задавался»). Фиксовый coercegetAttribute(...) || nullканонизирует такой сохранённый""к дефолту (null) на ПЕРВОМ sync-round-trip — разовый дифф"" → null— и документ байт-стабилен со ВТОРОГО прохода. Это каноническая нормализация конвертера, не потеря данных: срабатывает максимум один раз на затронутую ноду. Будущий sync/QA-проход, диффящий страницы, НЕ должен файлить этот единственный"" → nullкак «конвертер портит страницы».Добавил в матрицу контракт
runConvergenceCase(флагemptyStringClass): проход 1 обязан свести атрибут к дефолту (первопроходную байт-стабильность против""-входа НЕ ассертим — это и есть ожидаемая разовая нормализация), проход 2 обязан быть deep-equal проходу 1 (идемпотентность). Прогнан по каждому атрибуту класса на image + всей медиа-семье.📋 Объективка
round-trip third state: 18 passed (10 атрибутов класса);Ре-ревью — #350 (fix converter: round-trip стабильность), round 2, голова
c192f2a2. Вердикт: PASS ✅Существенная часть round-1 закрыта: добавлен контракт
runConvergenceCase(третье состояние класса — литеральный сохранённый""), который (а) делает реальный багimage.altНЕвакуумно покрытым, (б) документирует и проверяет ключевое свойство — сохранённый""нормализуется к дефолту РОВНО ОДИН раз (первопроходный"" → null), а со второго прохода документ байт-стабилен (идемпотентность). Это правильная канон-нормализация, не потеря данных — важно, что будущий sync/QA-дифф не должен файлить единичный"" → nullкак «конвертер портит страницы». Хорошее уточнение.Сам фикс корректен (parse-side
|| null, правильный слой, регрессий нет — сверено 9 аспектами в round 1). Объективка зелёная: пакетный vitest 682 passed (×5 прогонов стабильно) + mixed-combo (schema-contract + stability) 21/21 ×8; consumers (mcp roundtrip 67/0, git-sync 268, server tsc 0) зелёные с round 1 (исходник конвертера не менялся, только тесты).Остаются 2 микро-нита (documentation, below-threshold — НЕ блокирую) + 1 пред-существующий out-of-scope. Пропускаю по DROP-floor: пинать корректный, хорошо покрытый фикс ещё раунд ради комментариев не стоит. Детали ниже — оператору решать, брать ли.
📋 Калибровка (доки-ниты) + out-of-scope (пред-существующий флак) + что сверено
⛔ DROP — кодеру НЕ обязательно · калибровочный лог (оператору)
[below-threshold]low[documentation] Докстринг матрицы (roundtrip-stability.test.ts:25) всё ещё говорит «one matrix guards the shared class», хотя эмпирически (откат фикса) падает ТОЛЬКОimage.alt; строки медиа-семьи (video/attachment/pdf/diagram) и их convergence-кейсы вакуумны — сериализаторы опускают falsy-атрибуты,getAttributeвозвращаетnull(не""), и|| null— no-op. Строки реальны и структурно подметают семью (forward-looking guard, если сериализатор когда-нибудь начнёт эмитить пустой атрибут), поэтому автор вправе оставить — но точнее было бы сказать «image.alt — единственная строка, воспроизводящая баг; медиа — forward-looking». Реальный баг покрыт честно, так что это косметика докстринга — DROP.[below-threshold]low[documentation] Комментарий в фикс-хунке (docmost-schema.ts:~1519) всё ещё говорит «|| undefinedkeeps the truthy value», а код —|| null(само-противоречие, 3 ревьюера отметили). Тривиально, безвредно (дефолт всё равно null) — DROP.Вне scope — пред-существующий, НЕ вводится этим PR (кандидат на отдельную задачу)
global.document(markdown-to-prosemirror.ts:248-253, присваивается на уровне модуля). Новый sensitive stability-тест РЕДКО вскрывает связанный флак (image.alt: absent -> ""): поймал его 2 раза на прошлой голове2ce67270(мой mixed-прогон + независимый reviewer ~1/14), но на текущейc192f2a2— 0 из 13 прогонов (исходник расы не менялся; +18 convergence-кейсов, похоже, сдвинули тайминг). Прод безопасен (глобал ставится один раз при загрузке модуля, не пер-вызов;generateJSONсинхронный → живые конкурентные конвертации не interleave-корраптят). Риск — только недетерминированный CI-red под параллельным сьютом. Рекомендация: если once начнёт краснить CI — изолировать stability-тест-файл (свой JSDOM/непараллельный прогон) либо убрать модульную мутациюglobal.*. Пред-существующее, не блокер #350. Скажи — заведу трекинг-issue.Сверено (голова
c192f2a2): фикс исходника не менялся с round 1 (диф = только 2 тест-файла:roundtrip-stability.helper.ts+138,.test.ts+56);runConvergenceCaseкорректно моделирует третье состояние (authored""→ rt1 сводит к дефолту → rt2 deep-equal rt1);emptyStringClass-флаг верно выставлен только на null-дефолтных строковых атрибутах (embedproviderдефолт""исключён); объективка зелёная и стабильная в моих прогонах.