docs: specify the capture and anchor gates (#422)

User-Visible: no
Issue: #422
This commit is contained in:
Codex
2026-09-02 22:10:00 +03:00
parent 9811674195
commit 70805c9c6b
+208
View File
@@ -0,0 +1,208 @@
# ТЗ #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`: команда локального кросс-прогонного сравнения съёмки.
- Скриншоты не меняются: задача проверяет съёмку, а не кадры.