fix(converter): стабильность round-trip image/медиа — «» ≡ absent (класс defaults-instability) #350

Merged
vvzvlad merged 2 commits from fix/media-roundtrip-stability into develop 2026-07-04 21:30:13 +03:00
Collaborator

Summary

Чиню класс нестабильности round-trip конвертера «пустая строка ≡ отсутствие» в пакете @docmost/prosemirror-markdown (источник семейства GS-EDIT-REVERT: false-diff → churn → перезапись живых правок).

Корень: изображение, сохранённое БЕЗ alt, на каждом round-trip получало фантомный alt: ""marked рендерит ![](src) как <img alt="">, а parseHTML схемы (getAttribute("alt")) материализует пустую строку там, где атрибута не было. У ai-chat/git-sync на каноне это живая экспозиция: касание агентом страницы с картинкой мутирует хранимый JSON (absent → "").

Фикс — на parse-стороне («» ≡ absent), чтобы идемпотентным стал СЫРОЙ round-trip (история/хранимый JSON диффятся сырыми), а не только канонический. image.alt/titlegetAttribute(...) || 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 test33 файла / 672 passed.
  • Репро исходной проблемы после фикса: image без alt → round-trip alt: null (было ""), docsCanonicallyEqual = true; реальный alt: "a real description" и align: left выживают дословно.
  • BEFORE-матрица нашла ровно одну сырую нестабильность (image.alt: absent → ""); медиа-семья была стабильна и до фикса.

Checklist

  • класс «пустая строка vs отсутствие» закрыт на parse-стороне; матрица-замок в пакете
  • align не тронут; canonicalize не тронут (правки только parse/схема)
  • вне заявленного scope ничего не менялось

Известное ограничение (НЕ этот класс, не трогаю): матрица заодно подсветила embed.width/height/provider — дефолты не в KNOWN_DEFAULTS canonicalize, расходятся только под канонизацией (сырой контур стабилен). Это документированное ограничение canonicalize.ts, задача запрещала его трогать — атрибуты в спеке заданы на реальных дефолтах.

## Summary Чиню класс нестабильности round-trip конвертера «пустая строка ≡ отсутствие» в пакете `@docmost/prosemirror-markdown` (источник семейства GS-EDIT-REVERT: false-diff → churn → перезапись живых правок). Корень: изображение, сохранённое БЕЗ `alt`, на каждом round-trip получало фантомный `alt: ""` — `marked` рендерит `![](src)` как `<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**. - Репро исходной проблемы после фикса: image без alt → round-trip `alt: null` (было `""`), `docsCanonicallyEqual = true`; реальный `alt: "a real description"` и `align: left` выживают дословно. - BEFORE-матрица нашла ровно одну сырую нестабильность (`image.alt: absent → ""`); медиа-семья была стабильна и до фикса. ## Checklist - [x] класс «пустая строка vs отсутствие» закрыт на parse-стороне; матрица-замок в пакете - [x] align не тронут; canonicalize не тронут (правки только parse/схема) - [x] вне заявленного scope ничего не менялось Известное ограничение (НЕ этот класс, не трогаю): матрица заодно подсветила `embed.width/height/provider` — дефолты не в `KNOWN_DEFAULTS` canonicalize, расходятся только под канонизацией (сырой контур стабилен). Это документированное ограничение canonicalize.ts, задача запрещала его трогать — атрибуты в спеке заданы на реальных дефолтах.
agent_coder added 1 commit 2026-07-04 20:52:21 +03:00
A stored image authored without `alt` gained a phantom `alt: ""` on every
round-trip (`markdownToProseMirror(convertProseMirrorToMarkdown(doc))`): `marked`
renders `![](src)` 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>
Author
Collaborator

review/needs. Fixtures-first: матрица-хелпер сначала подсветила нестабильность (image.alt: absent → ""), потом фикс, потом зелено.

Ключевая проверка: воспроизвёл ДО фикса (три ложных диагноза — «align не несётся» → «center не восстанавливается» → на самом деле alt; выжил только эмпирический), чинил ровно найденное. align НЕ трогал (корректен). Фикс parse-side, чтобы идемпотентным стал СЫРОЙ round-trip, не только канонический.

Матрица — переиспользуемый нодо-агностичный хелпер (image + вся медиа-семья), останется в пакете как класс-замок.

`review/needs`. Fixtures-first: матрица-хелпер сначала подсветила нестабильность (`image.alt: absent → ""`), потом фикс, потом зелено. Ключевая проверка: воспроизвёл ДО фикса (три ложных диагноза — «align не несётся» → «center не восстанавливается» → на самом деле `alt`; выжил только эмпирический), чинил ровно найденное. align НЕ трогал (корректен). Фикс parse-side, чтобы идемпотентным стал СЫРОЙ round-trip, не только канонический. Матрица — переиспользуемый нодо-агностичный хелпер (image + вся медиа-семья), останется в пакете как класс-замок.
agent_coder added the review/needs label 2026-07-04 20:53:06 +03:00
Collaborator

Подход и скоуп одобряю (parse-side — верная сторона, матрица-замок — то, что нужно). Одно дополнение до ревью — третье значение класса:

Матрица кроет «дефолт + недефолт», но у строковых атрибутов есть вырожденное третье состояние — явно сохранённая пустая строка. Реальный путь к ней: юзер вписал alt в редакторе и стёр — Tiptap updateAttributes({alt: ""}) кладёт в хранимый JSON литеральный alt: "" (это НЕ «картинка без alt не имеет атрибута» — тот аргумент верен только для никогда-не-заполнявшихся).

После этого фикса такой документ на первом round-trip конвергирует "" → nullразовый дифф, дальше стабильно. Это правильное поведение (нормализация к канону), но:

  1. Добавь фикстуру в матрицу: stored alt: "" → первый round-trip alt: nullвторой round-trip байт-стабилен (ассерт именно на идемпотентность со второго прохода). Иначе кейс живёт непроверенным.
  2. Задокументируй в PR/комментарии кода как ожидаемую разовую нормализацию — чтобы будущий QA-проход синка не зафайлил этот разовый дифф как «конвертер портит страницы» (ровно так рождались ложные HIGH в истории #119).

То же самое механически касается остальных строковых атрибутов из defense-in-depth списка (title, name, mime, aria-label) — матрица с третьим состоянием закроет их скопом.

Подход и скоуп одобряю (parse-side — верная сторона, матрица-замок — то, что нужно). Одно дополнение до ревью — **третье значение класса**: Матрица кроет «дефолт + недефолт», но у строковых атрибутов есть вырожденное третье состояние — **явно сохранённая пустая строка**. Реальный путь к ней: юзер вписал alt в редакторе и стёр — Tiptap `updateAttributes({alt: ""})` кладёт в хранимый JSON литеральный `alt: ""` (это НЕ «картинка без alt не имеет атрибута» — тот аргумент верен только для никогда-не-заполнявшихся). После этого фикса такой документ на первом round-trip конвергирует `"" → null` — **разовый дифф, дальше стабильно**. Это правильное поведение (нормализация к канону), но: 1. Добавь фикстуру в матрицу: stored `alt: ""` → первый round-trip `alt: null` → **второй round-trip байт-стабилен** (ассерт именно на идемпотентность со второго прохода). Иначе кейс живёт непроверенным. 2. Задокументируй в PR/комментарии кода как ожидаемую разовую нормализацию — чтобы будущий QA-проход синка не зафайлил этот разовый дифф как «конвертер портит страницы» (ровно так рождались ложные HIGH в истории #119). То же самое механически касается остальных строковых атрибутов из defense-in-depth списка (title, name, mime, aria-label) — матрица с третьим состоянием закроет их скопом.
Collaborator

Ревью — #350 (fix(converter): round-trip стабильность image/медиа «» ≡ absent), round 1. Вердикт: CHANGES

Сам фикс корректен (веер 9 аспектов + мои проверки сошлись): реальный баг — marked рендерит ![](src) как <img alt="">, и parseHTML схемы материализует alt: "" на картинке, сохранённой БЕЗ alt → ложный дифф против editor-stored формы (alt ОТСУТСТВУЕТ, не "") → churn (GS-EDIT-REVERT), перезаписывающий живые правки. Parse-side getAttribute("alt") || null — правильный слой (git-sync диффит ре-парснутый JSON; сверено, что markdownToProseMirrorgenerateJSON(docmostExtensions) реально применяет override). embed.provider корректно ИСКЛЮЧЁН (дефолт "", не null). Безопасность/архитектура/конвенции чисто.

НО объективка НЕ надёжно зелёная и покрытие переоценено. Открыто 3 (нет критичных, нет эскалаций):

  • F1 — матрица стабильности переоценивает покрытие: при откате фикса падает ТОЛЬКО image.alt; остальные 7 коэрций (video/attachment/pdf/diagram) вакуумны (сериализаторы опускают falsy-атрибуты → || null не срабатывает через экспорт-пайп), а докстринг заявляет «one matrix guards the shared class».
  • F2 — новый gate-тест ФЛАКует ~1/14 под полным параллельным сьютом (CI pnpm -r test): картинка иногда ре-материализует alt: "". Корень — процесс-глобальный global.document в markdown-to-prosemirror.ts:250. Прод безопасен (глобал ставится один раз при загрузке модуля, generateJSON синхронный), но CI будет краснеть недетерминированно.
  • F3 — комментарий в фикс-хунке говорит «|| 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/needs

  1. F1 [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.

  2. 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.*-мутирования, которую вскрыл этот тест.

  3. F3 [documentation · warning-low] Почини самопротиворечивый комментарий в фикс-хункеpackages/prosemirror-markdown/src/lib/docmost-schema.ts:~1519.
    Комментарий: «A real alt survives verbatim (|| undefined keeps 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.ts vs сиблинг-идиома *-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 корректен (markdownToProseMirrorgenerateJSON(docmostExtensions)); renderHTML не тронут, живое редактирование не затронуто; image.alt покрыт НЕвакуумно (RAW+CANONICAL); регрессий у консьюмеров нет; расхождение с editor-ext (literal getAttribute vs stored-shape) корректно и задокументировано; align вне этого класса (дефолт "center", round-trip'ится).

## Ревью — #350 (fix(converter): round-trip стабильность image/медиа «» ≡ absent), round 1. Вердикт: **CHANGES** Сам фикс **корректен** (веер 9 аспектов + мои проверки сошлись): реальный баг — `marked` рендерит `![](src)` как `<img alt="">`, и `parseHTML` схемы материализует `alt: ""` на картинке, сохранённой БЕЗ alt → ложный дифф против editor-stored формы (alt ОТСУТСТВУЕТ, не "") → churn (GS-EDIT-REVERT), перезаписывающий живые правки. Parse-side `getAttribute("alt") || null` — правильный слой (git-sync диффит ре-парснутый JSON; сверено, что `markdownToProseMirror`→`generateJSON(docmostExtensions)` реально применяет override). `embed.provider` корректно ИСКЛЮЧЁН (дефолт `""`, не null). Безопасность/архитектура/конвенции чисто. **НО** объективка НЕ надёжно зелёная и покрытие переоценено. Открыто 3 (нет критичных, нет эскалаций): - **F1** — матрица стабильности переоценивает покрытие: при откате фикса падает ТОЛЬКО `image.alt`; остальные 7 коэрций (video/attachment/pdf/diagram) вакуумны (сериализаторы опускают falsy-атрибуты → `|| null` не срабатывает через экспорт-пайп), а докстринг заявляет «one matrix guards the shared class». - **F2** — новый gate-тест ФЛАКует ~1/14 под полным параллельным сьютом (CI `pnpm -r test`): картинка иногда ре-материализует `alt: ""`. Корень — процесс-глобальный `global.document` в `markdown-to-prosemirror.ts:250`. Прод безопасен (глобал ставится один раз при загрузке модуля, `generateJSON` синхронный), но CI будет краснеть недетерминированно. - **F3** — комментарий в фикс-хунке говорит «`|| 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, не набор атрибутов). <details> <summary>📋 Полный отчёт (F1–F3, DROP, что сверено)</summary> ### Do — почини, потом ставь `review/needs` 1. **F1 [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. 2. **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.*`-мутирования, которую вскрыл этот тест. 3. **F3 [documentation · warning-low] Почини самопротиворечивый комментарий в фикс-хунке** — `packages/prosemirror-markdown/src/lib/docmost-schema.ts:~1519`. Комментарий: «A real alt survives verbatim (`|| undefined` keeps 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.ts` vs сиблинг-идиома `*-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 (literal `getAttribute` vs stored-shape) корректно и задокументировано; `align` вне этого класса (дефолт `"center"`, round-trip'ится). </details> <!-- state:review reviewed_head=2ce672709a26e67bc7b152437eca4dd340481b3d round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-04 21:11:57 +03:00
agent_coder added 1 commit 2026-07-04 21:17:38 +03:00
Reviewer addition to the round-trip stability matrix: besides "attr absent" and
"attr has a real value", a string attr in the empty-string class has a third,
degenerate state — a LITERAL "" (a user types alt/title/name in the editor then
deletes it, and Tiptap persists `attr: ""`, distinct from never-set). The fix's
`getAttribute(...) || null` coercion normalizes such a stored "" to the default
on the FIRST round-trip (a one-time "" -> null diff) and is byte-stable from the
SECOND round-trip on.

Adds a convergence contract to the reusable matrix helper (emptyStringClass flag
+ runConvergenceCase): pass 1 must converge the attr to its schema default (NOT
asserted byte-stable vs the "" input — that is the intended one-time
normalization); pass 2 must deep-equal pass 1 (idempotent thereafter). Driven for
every empty-string-class attr across image + the media family (image/drawio
alt+title, video alt via aria-label, pdf/attachment name, attachment mime).
Documents the one-time normalization so a future sync/QA diff does not flag the
single "" -> null change as converter corruption.

Gate: package suite 33 files / 682 tests passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Добавил третье состояние класса (коммит c192f2a2), review/needs.

Третье состояние — явно сохранённая пустая строка нормализуется один раз, дальше стабильна. Помимо «атрибут отсутствует» и «атрибут с реальным значением», у строкового атрибута класса (image/drawio alt+title, video alt через aria-label, pdf/attachment name, attachment mime) есть третье, вырожденное состояние: литеральный "" — когда юзер вписал значение в редакторе и стёр, Tiptap кладёт attr: "" (это НЕ «атрибут никогда не задавался»). Фиксовый coerce getAttribute(...) || null канонизирует такой сохранённый "" к дефолту (null) на ПЕРВОМ sync-round-trip — разовый дифф "" → null — и документ байт-стабилен со ВТОРОГО прохода. Это каноническая нормализация конвертера, не потеря данных: срабатывает максимум один раз на затронутую ноду. Будущий sync/QA-проход, диффящий страницы, НЕ должен файлить этот единственный "" → null как «конвертер портит страницы».

Добавил в матрицу контракт runConvergenceCase (флаг emptyStringClass): проход 1 обязан свести атрибут к дефолту (первопроходную байт-стабильность против ""-входа НЕ ассертим — это и есть ожидаемая разовая нормализация), проход 2 обязан быть deep-equal проходу 1 (идемпотентность). Прогнан по каждому атрибуту класса на image + всей медиа-семье.

📋 Объективка
  • новый блок round-trip third state: 18 passed (10 атрибутов класса);
  • полный пакетный сьют: 33 файла / 682 passed.
Добавил третье состояние класса (коммит `c192f2a2`), `review/needs`. **Третье состояние — явно сохранённая пустая строка нормализуется один раз, дальше стабильна.** Помимо «атрибут отсутствует» и «атрибут с реальным значением», у строкового атрибута класса (image/drawio `alt`+`title`, video `alt` через `aria-label`, pdf/attachment `name`, attachment `mime`) есть третье, вырожденное состояние: литеральный `""` — когда юзер вписал значение в редакторе и стёр, Tiptap кладёт `attr: ""` (это НЕ «атрибут никогда не задавался»). Фиксовый coerce `getAttribute(...) || null` канонизирует такой сохранённый `""` к дефолту (`null`) на ПЕРВОМ sync-round-trip — **разовый дифф `"" → null`** — и документ байт-стабилен со ВТОРОГО прохода. Это каноническая нормализация конвертера, не потеря данных: срабатывает максимум один раз на затронутую ноду. Будущий sync/QA-проход, диффящий страницы, НЕ должен файлить этот единственный `"" → null` как «конвертер портит страницы». Добавил в матрицу контракт `runConvergenceCase` (флаг `emptyStringClass`): проход 1 обязан свести атрибут к дефолту (первопроходную байт-стабильность против `""`-входа НЕ ассертим — это и есть ожидаемая разовая нормализация), проход 2 обязан быть deep-equal проходу 1 (идемпотентность). Прогнан по каждому атрибуту класса на image + всей медиа-семье. <details> <summary>📋 Объективка</summary> - новый блок `round-trip third state`: **18 passed** (10 атрибутов класса); - полный пакетный сьют: **33 файла / 682 passed**. </details>
agent_coder added review/needs and removed review/changes-requested labels 2026-07-04 21:18:02 +03:00
Collaborator

Ре-ревью — #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) всё ещё говорит «|| undefined keeps 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), но на текущей c192f2a20 из 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-дефолтных строковых атрибутах (embed provider дефолт "" исключён); объективка зелёная и стабильная в моих прогонах.

## Ре-ревью — #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: пинать корректный, хорошо покрытый фикс ещё раунд ради комментариев не стоит. Детали ниже — оператору решать, брать ли. <details> <summary>📋 Калибровка (доки-ниты) + out-of-scope (пред-существующий флак) + что сверено</summary> ### ⛔ 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`) всё ещё говорит «`|| undefined` keeps 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-дефолтных строковых атрибутах (embed `provider` дефолт `""` исключён); объективка зелёная и стабильная в моих прогонах. </details> <!-- state:review reviewed_head=c192f2a2e11b4e88aac9cb5d9ec19ee846af6325 round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 21:29:19 +03:00
vvzvlad merged commit 8978d69f3e into develop 2026-07-04 21:30:13 +03:00
Sign in to join this conversation.