# ТЗ #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`: ```js 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/`): 1. `capture-clip.test.mjs` — целочисленная обрезка на дробных входах (AC4). 2. `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`: команда локального кросс-прогонного сравнения съёмки. - Скриншоты не меняются: задача проверяет съёмку, а не кадры.