mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
229 lines
21 KiB
Markdown
229 lines
21 KiB
Markdown
# 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
|
||
```
|