From 70805c9c6bb08c8bd3cf176c6dccee169fbc330b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 2 Sep 2026 19:26:53 +0300 Subject: [PATCH] docs: specify the capture and anchor gates (#422) User-Visible: no Issue: #422 --- docs/specs/422-capture-and-anchor-gates.md | 208 +++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/specs/422-capture-and-anchor-gates.md diff --git a/docs/specs/422-capture-and-anchor-gates.md b/docs/specs/422-capture-and-anchor-gates.md new file mode 100644 index 00000000..b1a2d2af --- /dev/null +++ b/docs/specs/422-capture-and-anchor-gates.md @@ -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`: команда локального кросс-прогонного сравнения съёмки. +- Скриншоты не меняются: задача проверяет съёмку, а не кадры.