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

16 KiB
Raw Blame History

ТЗ #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/):

  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: команда локального кросс-прогонного сравнения съёмки.
  • Скриншоты не меняются: задача проверяет съёмку, а не кадры.