Files
houseplan-card/docs/reviews/CODE-REVIEW-472-r1.md
T
2026-09-06 15:29:50 +03:00

229 lines
21 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.
# CODE-REVIEW-472-r1
Issue: #472 — «Полный мутационный прогон по расписанию падал дважды подряд без
реакции: у отказа нет адресата». Лёгкий трек (`small`), ТЗ в теле issue,
принято владельцем в арбитраже после исчерпания бюджета ревью ТЗ (2/2,
SPEC-REVIEW-472-r1, -r2). Код-ревью — первый заход, бюджет циклов 0/2.
## Материал раунда
- SHA материала: `dec6c03e89af48f570bf60994b99aeacba0cb966` (HEAD на момент
ревью, `git rev-parse HEAD`)
- Дерево: `2e66a9609af6d88f98375efdad4af8be1758a524` (`git rev-parse HEAD^{tree}`)
- Один коммит на весь диапазон `origin/dev..HEAD`: `dec6c03e ci: отчёт об
отказе расписания мутантов — issue и Telegram`, трейлеры `Issue: #472`,
`User-Visible: no`.
- **Ветка приведена к `dev` конвейером до ревью**: поверх исходного `a8ea3b8a`
легло 5 коммитов `dev` (rebase), финал — `dec6c03e`. После ребейза это
другой код (PROCESS.md §7.2), но поскольку это первый заход ревью кода
(r1), полный разбор требовался бы в любом случае — §2.10 (объём по дельте)
относится ко второму и последующим заходам.
- Реализация подтверждена только рабочей веткой (`Validate` зелёный на
`a8ea3b8a`, run 34030761200 — до ребейза); на `dec6c03e` отдельного
зелёного прогона не было, поэтому дешёвые гейты прогнаны заново мной,
ниже.
## Скоуп диффа
```
.github/workflows/mutation-gate.yml | 105 ++++++++++++++++++++-
.github/workflows/validate.yml | 28 +++---
scripts/mutation-gate-report.mjs | 177 ++++++++++++++++++++++++++++++++++++ (новый)
scripts/mutation-gate.mjs | 34 +++++++ (3 мутанта)
test/mutation-gate-report.test.mjs | 99 ++++++++++++++++++++ (новый, 8 тестов)
test/mutation-gate.test.mjs | 49 ++++++++++ (7 контрактных тестов)
```
Класс B целиком (workflows, scripts, test) — ни один файл `src/**`,
`custom_components/**/*.py`, манифестов или i18n не тронут. Пользователю
видимых изменений нет (`User-Visible: no` корректен). `docs/SCOPE.md`
неприменим содержательно: задача обслуживает сам процесс разработки, а не
персону из ядра продукта, что и объявлено меткой `process`/`infra` —
скоуп-рамка не нарушена, потому что не заявлена.
## Как проверялось — гейты
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный, 0 ошибок |
| unit | `npm test` | 2091 тестов, pass 2090, fail 0, skipped 1 (существующий, не мой) |
| build + сверка 3 копий бандла | `npm run build && cmp dist/… custom_components/…` затем `npm run bundle:sync && cmp` (dist / custom_components / demo/srv/assets) | все три копии побайтово совпадают |
| provenance | `node scripts/validate-commit-provenance.mjs dec6c03e~1..dec6c03e` | exit 0, трейлеры корректны |
| YAML-синтаксис изменённых workflow | `python3 -c "yaml.safe_load(...)"` на `mutation-gate.yml` и `validate.yml` | оба парсятся |
| new-any | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | 0 добавленных строк в `src/**` — гейт неприменим, диффа во фронтенде нет |
| smoke-select | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет… смоки этим диффом не выбираются» — браузерные смоки не нужны |
| check-docs | не запускал | diff не трогает `src/**` — гейт не входит в обязательную часть (PROCESS.md §8) |
| golden/pytest backend/performance | не запускал | нет визуальных, Python- или perf-чувствительных изменений; ни один не назван в AC |
| model-invariants | не запускал | геометрия, `layout`, `marker.space`, толщина стен не затронуты |
Три мутанта задачи (`mutation-report-drops-missing-shard`,
`mutation-report-duplicates-escaped`, `mutation-report-red-guard-as-mutant`)
прогнаны индивидуально, не только `--check`:
```
node scripts/mutation-gate.mjs --id=mutation-report-drops-missing-shard → поймано 1 из 1 (16.4s)
node scripts/mutation-gate.mjs --id=mutation-report-duplicates-escaped → поймано 1 из 1 (15.9s)
node scripts/mutation-gate.mjs --id=mutation-report-red-guard-as-mutant → поймано 1 из 1 (15.9s)
```
Каждый — реальный прогон в изолированном git-worktree (патч → `test-build/` →
guard), не просмотр кода: «тест умеет падать» доказано исполнением, а не
заявлением автора.
## Критерии приёмки — таблица «чем доказан · чем краснеет»
| AC | Доказано | Чем краснеет |
|---|---|---|
| AC1 (группы concurrency разные) | контрактный тест `test/mutation-gate.test.mjs` + чтение `mutation-gate.yml:37` (`group: mutation-gate-${{ github.event_name }}`) | тест матчит буквальную строку с суффиксом события; без суффикса regex не совпадёт — «проверено чтением», мутанта на YAML в реестре нет (спецификой issue не требовалось) |
| AC2 (лог шарда — артефакт, `if: always()`, код выхода сохранён) | контрактный тест + чтение: `set -o pipefail` перед `tee`, `if: always()` на шаге `Сохранить лог шарда`, `if-no-files-found: warn` | то же — «чтением»; пайплайн PIPESTATUS проверен вручную: `tee` без `pipefail` замаскировал бы ненулевой код `node`, тест ловит именно строку `set -o pipefail` |
| AC3 (сбор escaped/redGuards/unparsed, дедуп, `missing`) | `test/mutation-gate-report.test.mjs`, 8 юнитов + 3 мутанта на реальном прогоне (см. выше) | мутация подтверждена исполнением: снятие проверки `known.has(...)`, дедупликации и статуса `missing` красит соответствующие тесты — «поймано 1 из 1» на каждом |
| AC4 (заголовок с маркером, тело — прогон/SHA/`--id=`) | юнит `#472 AC4` | прогон со снятой защитой не проводился отдельно (простая сборка строки), но структура тела читается в `mutationGateReport` — «чтением» + юнит проверяет фактическое присутствие подстрок |
| AC4b (отдельные разделы redGuards/unparsed, ни один не превращается в `--id=`) | юниты «красный гард…» и «id вне реестра…» | оба закрывают M3 из SPEC-REVIEW r2; мутант `mutation-report-red-guard-as-mutant` воспроизводит именно наивный парсер и подтверждён прогоном (см. выше) |
| AC5 (job `report`: только schedule+не-success, права полностью) | контрактный тест + чтение permissions-блока | тест ищет буквально три строки прав и `if:`; неполные права не совпадут — «чтением» |
| AC6 (комментарий вместо второго issue) | контрактный тест на порядок `gh issue list` → `comment`/`create` + анализ `jq`-фильтра (см. ниже, воспроизведено локально) | «чтением» на структуру workflow; сама логика `startswith(env.MARKER)` проверена мной вживую `jq`-выражением на фикстуре — см. раздел «Проверено дополнительно» |
| AC7 (нет Telegram-секретов — не отказ) | контрактный тест + чтение шага `Telegram` | «чтением»: `if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then … exit 0` — при отсутствии секретов шаг явно завершает `0` |
| AC8 (`mutation-gate.yml` под сверкой main/dev наравне с `process.yml`) | контрактный тест + чтение `validate.yml:65-77` | «чтением»: цикл `for file in process.yml mutation-gate.yml` печатает `РАСХОЖДЕНИЕ …` и ставит `status=1` для любого из двух файлов, `exit $status` в конце — тест матчит буквальный `for`-заголовок |
Все восемь AC выполнены. Мутанты «Мутанты» (issue) — три штуки, все три
подтверждены прогоном, «поймано 1 из 1» на каждом.
## Проверено дополнительно (сверх минимума)
- **Плюмбинг артефактов пройден логически до конца.** `actions/upload-artifact`
для одиночного файла кладёт его в корень артефакта; `actions/download-artifact`
с `pattern: mutation-shard-*` и без `merge-multiple` разводит каждый по
подпапке `<artifact-name>/…` — итоговый путь `artifacts/mutation-logs/
mutation-shard-<N>/mutation-shard-<N>.log` совпадает с тем, что читает
`mutation-gate-report.mjs` (`${dir}/mutation-shard-${shard}/mutation-shard-${shard}.log`)
дословно. Отсутствующий артефакт (шард упал до шага лога) не роняет
`download-artifact` (`if-no-artifact-found` по умолчанию `warn`) и корректно
даёт `existsSync === false` → `status: 'missing'`.
- **`jq`-фильтр в шаге «Issue — создать или дописать» воспроизведён локально:**
```
echo '[{"number":1,"title":"[mutation-gate] отказ прогона по расписанию: 2026-09-06"},{"number":2,"title":"other"}]' \
| MARKER='[mutation-gate] отказ прогона по расписанию' \
jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty'
→ 1
```
`env.MARKER` внутри `--jq`-выражения работает так, как рассчитывал автор.
- **`workflow_sync` для двух файлов** проверен и на `git show` для текущих
`origin/main`/`origin/dev`: `process.yml` и (до этой задачи) `mutation-gate.yml`
сейчас идентичны в обеих ветках — гейт AC8 не будет ложно красным сразу после
мержа.
## Находки
### M1 — Medium, в скоупе. SHA в отчёте — не тот, что был протестирован
Контракт (issue, §3) и AC4 требуют в теле отчёта SHA `dev` — того дерева,
которое реально гоняли мутанты. Job `report` берёт его так:
```yaml
# mutation-gate.yml, job report
env:
SHA: ${{ github.sha }}
run: |
node scripts/mutation-gate-report.mjs … --ref=dev --sha="$SHA" …
```
`github.sha` для событий **`schedule`** — это не SHA ветки, которую явно
чекаутит job (`ref: dev`, как в `mutants`), а **последний коммит ветки по
умолчанию репозитория** (GitHub Actions context docs) — то есть здесь `main`,
который подтверждён отдельно: `gh repo view --json defaultBranchRef` → `main`.
Это ровно тот же класс путаницы, который сам issue разбирает в разделе
«Уточнение» («расписание значится на `main`, но код берётся из `dev`») — только
для `checkout` автор это учёл, а для `github.sha` в job `report` — нет.
**Воспроизведение.** Мутанты (`mutants` job) чекаутят `dev` явно
(`mutation-gate.yml:52`, `ref: ${{ github.event_name == 'workflow_dispatch' &&
inputs.ref || 'dev' }}`), а `report` подставляет в отчёт значение контекстной
переменной `github.sha`, вычисляемое GitHub независимо от шагов чекаута job'а
и для `schedule` равное вершине `main`. В любую неделю, где `dev` ушёл вперёд
`main` (обычное состояние между релизами — «Promotion rule» в `AGENTS.md`:
`main` двигается только промоушен-коммитами), тело отчёта напечатает
`` `dev` @ `<SHA main>` `` — SHA существующего коммита, но не того дерева, что
тестировали. Юнит-тест AC4 не ловит это: в него подставляется руками заданный
`sha: 'abcdef1234567890'`, минуя реальный источник значения.
**Практическое следствие.** Не ломает ни один из уже упомянутых AC (issue всё
ещё заводится/комментируется, мутанты названы верно, дедуп работает); теряется
только точная привязка «что именно тестировали» — при попытке
`git checkout <SHA>` для расследования человек попадёт на коммит `main`,
который в норме на несколько релизов отстаёт от `dev` и может не содержать
даже тех файлов, что упомянуты в отчёте. Ссылка на прогон (`RUN_URL`,
`github.run_id`) остаётся рабочим обходным путём — открыв run, можно увидеть
реальный SHA в логе шага checkout `mutants`, — поэтому это не тупик, а
неверная, но не единственная нить.
**Правка (в скоупе, дёшево):** в job `report` определить `SHA` из
чекаутнутого дерева, а не из контекста события — тем же приёмом, что уже
применяется в `process.yml`/`publish-prerelease.yml`/`release.yml` этого же
репозитория (`SHA=$(git rev-parse HEAD)` **после** `actions/checkout@v7` с
`ref: dev`), а не `${{ github.sha }}`.
### L1 — Low, не блокирует. Поиск открытого issue по заголовку не проверен вживую
AC6 полагается на `gh issue list --search "\"$MARKER\" in:title"` с
последующей фильтрацией `startswith` — сам `jq`-фильтр я воспроизвёл (см.
выше) и он корректен, но полнотекстовый поиск GitHub по заголовку с
кириллицей и квадратными скобками — часть, которую нельзя проверить без
живого обращения к API. Если полнотекстовый индекс не найдёт точную фразу
(маловероятно, но не исключено для не-ASCII), повторный отказ заведёт второе
issue вместо комментария — то есть тот же класс дефекта, который решает вся
задача, просто из-за внешней системы, а не кода. Контрактный тест проверяет
только порядок вызовов `gh`, не результат поиска. На усмотрение автора —
можно оставить и понаблюдать на первом реальном отказе по расписанию, риск
невысокий, а альтернатив без сети нет ни у одной из сторон.
## Что проверено и корректно
- Все восемь AC + AC4b — см. таблицу выше.
- Три мутанта задачи — прогнаны индивидуально, не только `--check`, каждый
«поймано 1 из 1».
- Артефактный плюмбинг `upload-artifact`/`download-artifact` — прослежен путь
файла от шага лога шарда до чтения в `mutation-gate-report.mjs` включительно.
- `jq`-выражение шага поиска issue — воспроизведено локально на фикстуре.
- `workflow_sync` в `validate.yml` — синтаксис и логика (два файла, оба
проверяются, оба влияют на итоговый код выхода).
- Дешёвые гейты (typecheck/test/build/сверка трёх копий бандла/provenance) —
зелёные на `dec6c03e`.
- Трейлеры коммита корректны; `User-Visible: no` обоснован (нет изменений
`src/**`); changelog не тронут — верно.
- Откат («Одним коммитом») — задача действительно один коммит, откат тривиален.
## Чего не проверял
- Живое поведение `gh issue list --search` против реального GitHub API с
кириллическим маркером (см. L1) — нет доступа к сети/API из среды ревью;
ближайшая проверка — первый реальный отказ по расписанию.
- Зеркалирование `mutation-gate.yml` в `main` после мержа — это ручной шаг вне
этого коммита (как и у `process.yml` сейчас), автор сам отметил его в
хендоффе («После мержа: зеркало … обязательно»); гейт AC8 проверяет факт
расхождения, а не проводит его — оставляю на исполнителя/владельца
постмержевого шага, это не код-ревью выявление.
- `golden`, `pytest tests_backend`, performance-профили, браузерные смоки — не
запускал: diff не трогает `src/**`/Python/визуал/perf-путь, ни один не
назван в AC (см. таблицу гейтов).
- Полный список из 255 мутантов (`node scripts/mutation-gate.mjs` без `--id`)
не гонял — это предрелизный/еженедельный гейт (PROCESS.md §8), не гейт
ревью; задача добавляет три мутанта, оба прогнаны точечно.
## Вердикт
Жёлтый: одна находка Medium в скоупе (M1 — неверный SHA в отчёте для
schedule-триггера), правится в этой же задаче без нового issue. Все AC по
существу выполнены, реализация работает; дефект — в одном поле метаданных
отчёта, не в основной функции (issue заводится/дополняется, мутанты названы
верно). Low (L1) — на усмотрение автора, не блокирует.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/472-mutation-gate-report`, коммит `a8ea3b8a2dcf` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `8fb963c4bfed17b31222ba6ce224af960009cdd0`
```
git log --all --format='%H %T' | grep 8fb963c4bfed
```