Files
houseplan-card/docs/specs/422-capture-and-anchor-gates.md
T

210 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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`: команда локального кросс-прогонного сравнения съёмки.
- Скриншоты не меняются: задача проверяет съёмку, а не кадры.