16 KiB
ТЗ #422 — Гейт съёмки меряет между прогонами, гейт якорей — достижимость
- Issue: https://github.com/Matysh/houseplan-card/issues/422
- Приоритет: P3, infra; полный трек — две несвязанные поверхности (съёмка
документации и гейт ревью-документа), критерий «одна поверхность» для
smallне выполняется. Класс файлов — только B (.github/**,demo/**,scripts/**,test/**), продуктового кода задача не касается; прецеденты инфраструктурных задач с файлом ТЗ — #398, #399, #404 - Ревизия: 1 (2026-09-02)
Сценарий
Две проверки, каждая заведена после конкретного инцидента, и каждая на своём же инциденте промолчала бы.
Первая: после #410 в конвейер добавлен шаг «Кадр не плавает внутри одного состояния». Он делает три снимка подряд в одном процессе. Дефект #410 был не такой: обрезка кадра плавала между прогонами, и три снимка внутри одного процесса совпали бы всегда.
Вторая: после #413 гейт отказывает ревью-документу, объявившему материал на
несуществующем коммите. После #414 отказ смягчается, если в машинном блоке есть
живые якоря — дерево и блоб, которые ребейз не меняет. Живость якоря проверяется
git cat-file -e, то есть наличием объекта в локальной базе. Объект, созданный
на машине автора и никуда не привязанный, этой проверке удовлетворяет.
Что человек увидит до и после
Видимого поведения продукта задача не меняет — меняется то, что видит инженер.
До: (1) съёмка может стать недетерминированной между прогонами, а гейт
останется зелёным; (2) отказ «материал ревью недостижим» смягчается объектом,
существующим только на машине, где ошибку и совершили.
После: недетерминированность съёмки между прогонами роняет конвейер;
якорь считается живым только если он достижим из refs/remotes/origin или
тегов — то есть если команда, которую документ печатает читателю, действительно
находит материал.
Проблема и контракты по пунктам
(1) Гейт стабильности съёмки
demo/docs/capture.mjs:290-312 в режиме --stability=N делает N снимков в
одном процессе без правки состояния и сравнивает их попиксельно.
.github/workflows/docs-screenshots.yml:99-100 вызывает его с --stability=3.
Воспроизведено исполнением. В capture.mjs внесена мутация, точно
моделирующая #410: смещение обрезки на 1 px, постоянное внутри процесса и
разное между прогонами.
node demo/docs/capture.mjs --stability=3 → «кадр не плавает ни в одном сценарии»
прогон A: 6ba168494519a10dd659
прогон Б: 4142a6ab9558cb481ecc ← 08-room-card.png разошёлся
Шаг «Хеши кадров» (:101-103) печатает sha256sum в лог, но ничего не
сравнивает: это материал для человека, а не гейт.
Контракт: воспроизводимость съёмки проверяется там, где она нарушалась —
между независимыми прогонами. Два полных запуска capture.mjs в разных
процессах на одном и том же коммите обязаны дать побайтово совпадающие кадры;
расхождение роняет конвейер и называет разошедшиеся файлы.
Проверка внутри одного состояния при этом остаётся: она отвечает на другой вопрос — «плавает ли кадр от времени внутри страницы» — и стоит дёшево. Одна не заменяет другую; вместе они покрывают обе оси, по которым съёмка может поехать.
Отдельно — корневая правка #410: целочисленная обрезка (capture.mjs:284-289)
не покрыта ничем. Выражение чистое (вход — прямоугольник с дробями, выход —
целый), значит проверяется юнитом без браузера.
(2) Живость якорей материала
scripts/review-doc-guard.mjs:305-307:
const resolveObjects = (object) => spawnSync('git', ['cat-file', '-e', object], …).status === 0;
git cat-file -e истинен для любого объекта в локальной базе, включая
недостижимый и обречённый на gc. Для SHA раунда достижимость намеренно
считается от refs/remotes/origin и тегов (:298-304), для якорей — нет.
Воспроизведено исполнением:
объект 8ce20a2a0a8b… (git hash-object -w, ни к чему не привязан)
git cat-file -e: признаёт живым
достижим из origin/тегов: НЕТ
git log --all --find-object: не находит
danglingMaterialRefusal(недостижимый SHA + этот якорь) → WARNING, отказ смягчён
Контракт: якорь считается живым тогда и только тогда, когда его находит
команда, которую документ печатает читателю (materialAnchorBlock), выполненная
в области refs/remotes/origin и тегов. Для дерева это перебор %T по тем же
ссылкам, для блоба — --find-object по ним же; тип объекта различается
git cat-file -t. Область --all не годится: она включает локальные ветки, а
их у ревьюера нет.
Граница: гейт по-прежнему судит момент публикации, а не будущее ветки (§«Чего этот рубеж НЕ умеет» в самом файле). Задача не меняет этой границы — она только приводит проверку якоря к той же строгости, что и проверка SHA.
Скоуп / не-скоуп
В скоупе: кросс-прогонная проверка съёмки (capture.mjs, шаг конвейера),
юнит на целочисленную обрезку, проверка достижимости якорей в
review-doc-guard.mjs, тесты и мутанты, комментарий о порядке шагов в
docs-screenshots.yml.
Не в скоупе: состав сценариев съёмки и их фикстуры; порог кадров-свидетелей (#408/#409 — закрыты); формат машинного блока якорей (#414); правило «SHA раунда обязан быть достижим» (#413) — оно не меняется; три проверки, не умеющие падать (#421) — соседний класс, своя задача.
UX, модель данных, i18n
Не применимо: продуктового кода задача не касается, пользовательских строк не добавляет.
Критерии приёмки
- AC1. Съёмка, недетерминированная между прогонами, роняет конвейер:
два независимых запуска
capture.mjsна одном коммите сравниваются побайтово, расхождение печатает имена файлов и завершается ненулевым кодом. Доказательство: прогон на коде с мутацией, моделирующей #410 (смещение обрезки, постоянное в процессе и разное между прогонами), — гейт красный. - AC2. Отрицательный прогон обязателен: та же мутация при старом гейте
(
--stability=3) остаётся зелёной. Доказательство: мутант вscripts/mutation-gate.mjs, прогнанный штатным раннером. - AC3. Проверка «кадр не плавает внутри одного состояния» сохранена и
по-прежнему краснеет на нестабильности внутри процесса. Доказательство:
существующий режим
--stabilityне тронут в части сравнения. - AC4. Целочисленная обрезка покрыта юнитом: дробный прямоугольник
расширяется до целого, цель не теряет ни полпикселя по краю.
Доказательство: тест в
test/, краснеющий при заменеMath.ceil/Math.floorна округление к ближайшему. - AC5. Якорь, существующий только локально, не смягчает отказ:
danglingMaterialRefusalс недостижимым SHA и таким якорем возвращает отказ, а не warning. Доказательство: тест с подставнымresolveObjects, отражающим достижимость, а не наличие. - AC6. Достижимый якорь по-прежнему смягчает отказ до warning — поведение
#414 не сломано. Доказательство: тот же тест, вторая ветка; существующие
тесты
review-doc-guardзелёные без правок их утверждений. - AC7. Дерево и блоб проверяются каждый своей командой, тип определяется
git cat-file -t; неизвестный тип объекта живым не считается. Доказательство: юнит на трёх входах. - AC8. Конвейер не стал заметно дольше: второй прогон съёмки — это ещё один
capture.mjsна том же браузере. Доказательство: замер до и после, разница названа в issue числом. - AC9. Комментарий о порядке шагов в
docs-screenshots.ymlсоответствует фактическому порядку. Доказательство: чтение файла — комментарий и порядок шагов сверяются глазами в одном экране.
План автотестов
Юниты (test/):
capture-clip.test.mjs— целочисленная обрезка на дробных входах (AC4).review-doc-guard.test.mjs(дополнение) — якорь недостижим → отказ; достижим → warning; неизвестный тип → не живой (AC5, AC6, AC7).
Гейт конвейера: шаг, запускающий съёмку дважды и сравнивающий хеши всех
кадров (AC1). Локально та же проверка доступна командой, названной в
docs/TESTING.md.
Мутанты (scripts/mutation-gate.mjs):
capture-drifts-between-runs: смещение обрезки, постоянное в процессе и разное между прогонами → новый кросс-прогонный гейт красный, старый--stabilityзелёный (AC1, AC2);anchor-liveness-ignores-reachability: вернутьgit cat-file -e→ тест AC5 красный.
Риски
- Второй прогон съёмки удваивает время шага. Съёмка десяти сценариев — не
самая долгая часть конвейера, но и не бесплатная. Смягчение: AC8 требует
замера, а не предположения; если удвоение окажется дорогим, второй прогон
можно свести к тем сценариям, где обрезка нетривиальна (
room-cardи соседи) — но тогда это должно быть записано, а не подразумеваться. - Кросс-прогонное сравнение поймает не только нашу недетерминированность. Обновление Chromium в кэше между двумя прогонами одного job'а невозможно, но системный шрифтовой кэш и подобное — теоретически да. Смягчение: оба прогона идут в одном job'е, на одном браузере и одном коммите; если гейт начнёт краснеть по внешней причине, это само по себе важная новость.
- Проверка достижимости якорей дороже
cat-file -e. Перебор%Tпо ссылкам origin — этоgit logпо всей истории. Смягчение: вызывается только в редкой ветке (SHA раунда уже недостижим), то есть на отказе, а не на каждом прогоне. - Ужесточение якорей сделает часть прошлых отчётов формально негодными. Это правда и это цель: смягчение, которое не воспроизводится, хуже отсутствия смягчения. Смягчение риска: сообщение отказа обязано называть команду, которой ревьюер может проверить якорь сам.
Откат
Обе правки локальны и независимы: шаг конвейера снимается отдельно от проверки достижимости. Данные пользователя и продуктовый код не затрагиваются.
Release-артефакты
docs/CHANGELOG.md/docs/CHANGELOG.ru.md: не требуется (User-Visible: no).docs/TESTING.md: команда локального кросс-прогонного сравнения съёмки.- Скриншоты не меняются: задача проверяет съёмку, а не кадры.