21 KiB
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" ] |
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' → 1env.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 берёт его так:
# 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) — на усмотрение автора, не блокирует.
Материал раунда
- Ветка:
issue/472-mutation-gate-report, коммитa8ea3b8a2dcf— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
8fb963c4bfed17b31222ba6ce224af960009cdd0git log --all --format='%H %T' | grep 8fb963c4bfed