# CODE-REVIEW-481-r1 Issue: #481 — `changed_mutants`: журнал пойманных свидетелей вместо суда по диапазону Этап: код-ревью (PROCESS.md §2.7) · трек: `small` (спека — в теле issue, спек-ревью — r1 жёлтый → r2 зелёный, оба принятых Medium закрыты текстом issue) Заход: r1 · блокирующих циклов 0 из 2 (бюджет код-ревью, отдельный от спек-ревью) SHA материала: `df4104ab386fd339d9bf99d61573215b0bc869da` (сверено `git rev-parse HEAD` непосредственно перед выводом) ## Материал раунда `git log --oneline origin/dev..HEAD`: ``` df4104ab docs: review document for #481 (класс C, коммит шага публикации спек-ревью r2 — не код автора) f8ae546f test: бамп версии не меняет отпечатки мутантов ядра; бандл пересобран c2913b28 docs: refresh screenshot source fingerprint 52aa3323 docs: журнал свидетелей в TESTING.md 97aabddc ci: журнал пойманных свидетелей для changed_mutants ``` Верхний коммит (`df4104ab`) добавляет только `docs/reviews/SPEC-REVIEW-481-r2.md` — это собственный артефакт конвейера (публикация принятого спек-ревью), не код автора задачи; в код-ревью не разбирается. Четыре нижних коммита — предмет этого раунда. Это первый заход код-ревью (`циклов 0/2`), разбор полный. Диапазон `origin/dev...HEAD`: 42 файла. Класс A (`src/**`, `custom_components/**/*.py`) не тронут ни одним файлом — задача целиком в классах B (`scripts/**`, `test/**`, `.github/workflows/**`) и C (`docs/**`), плюс класс D (`dist/**`, `custom_components/houseplan/frontend/**`) как механическое следствие того, что `scripts/source-fingerprint.mjs` входит в собственный корпус отпечатка сборки — поэтому бамп корпуса потребовал пересборки трёх копий бандла и обновления `docs/images/screenshots.json`, без единого изменённого пикселя (зафиксировано в сообщении коммита `c2913b28` и проверено мной запуском `check-docs`, см. ниже). Трейлеры на всех четырёх коммитах: `Issue: #481`, `User-Visible: no` — верно, видимого поведения продукта нет; оба changelog не тронуты и не должны быть. ## Как проверялось (гейты) Зелёного Validate на `df4104ab` нет — прогнал дешёвые гейты сам, на этом SHA: | Гейт | Команда | Результат | |---|---|---| | Типы | `npx tsc --noEmit` | ok, без ошибок | | Юниты | `npm test` | 2163 pass / 0 fail / 1 skipped (2164 всего) | | Сборка + 3 копии бандла | `npm run bundle:sync` | бандл собран, `custom_components/…/frontend` и `demo/srv/assets` синхронны, `git status` после — пусто | | Бюджет бандла | `npm run bundle:budget` | ok, initial View 288573 Б (потолок 289500±2000); предупреждение о запасе 12493 Б — долг #367, предсуществующий, этим диффом не создан | | Документация/скриншоты | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 12 external links)` | | Малый гейт целиком | `node scripts/gate-small.mjs` | все 6 шагов ok; `smoke-select` подтвердил: «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)» — браузерные смоки этим диффом не выбираются, гонять нечего | | Провенанс коммитов | `node scripts/validate-commit-provenance.mjs` | exit 0 | `src/**` диффом не задет — правило «check-docs обязателен, если диффа касается src/**» формально не применяется, но я всё равно прогнал его из-за самоссылки корпуса (`source-fingerprint.mjs` есть в `fingerprintFiles()`) — зелёный. Не прогонял и почему: - **browser smokes (`demo/smoke_*.mjs`)** — `smoke-select` по диффу не выбрал ни одного: фронтенд (`src/**`) не тронут, смоки гоняют собранную карточку, а не скрипты гейта. - **`npm run golden:verify`** — диффом не меняется ни рендер, ни геометрия, ни стили. - **`python -m pytest tests_backend -q`** — `custom_components/**/*.py` не тронут. Попутно (не как обязательный гейт, а как часть интеграционной проверки AC2 ниже) наткнулся на `FAIL … python3 -m pytest … No module named pytest` — это дыра окружения ревьюера (pytest не установлен), а не регресс диффа: соответствующий мутант к #481 отношения не имеет и не входит в реестр этой задачи. - **инварианты модели (`npm run invariants`)** — диффом не задеты рёбра комнат, толщина, `layout`, `marker.space`, `open_spans`. - **perf-профили** — не названы в AC, чувствительные пути не тронуты. ## AC → доказательство → чем краснеет (PROCESS.md §2.7, «таблица красноты») | AC | Чем доказан | Чем краснеет (мутация/негативная проба, результат) | |---|---|---| | AC1 (отпечаток версионно-неизменен, но чувствителен к правке патча/гарда/объявления, CRLF-нейтрален) | `node --test --test-name-pattern="#481 AC1" test/mutation-gate.test.mjs` | Свидетель `ledger-version-sensitive` (патч убирает вызов `normalize`): `node scripts/mutation-gate.mjs --id=ledger-version-sensitive` → `тест покраснел, как обязан`, поймано 1 из 1. Я вручную применил тот же патч к рабочей копии — `#481 AC1` падает: `AssertionError: бамп версии — не изменение свидетеля`, хеши расходятся | | AC2 (`--changed --ledger`: совпавший отпечаток → пропуск, чужой/отсутствующий → в прогон) | `node --test --test-name-pattern="#481 AC2" test/mutation-gate.test.mjs` (unit на `splitByLedger`) | Unit доказывает саму функцию отбора; **интеграционного CLI-теста на фикстуре, обещанного ТЗ («unit + CLI на фикстуре»), в диффе нет** — см. находку Low ниже. Я воспроизвёл интеграцию вручную: посчитал реальный отпечаток мутанта `ledger-version-sensitive` через `witnessFingerprint`, положил его в журнал и прогнал `node scripts/mutation-gate.mjs --changed=origin/dev..HEAD --ledger=` на настоящем реестре → `по журналу пропущено 1 (отпечатки совпали), к прогону 7` при 8 отобранных диффом — счёт verно, только помеченный мутант пропущен | | AC3 (журнал пишется сразу после каждого пойманного; сбежавший не пишется; переживает битый/чужой журнал) | `node --test --test-name-pattern="#481 AC3" test/mutation-gate.test.mjs` | Свидетель `ledger-written-at-end-only` (запись только после >1 записи): `--id=ledger-written-at-end-only` → поймано 1 из 1. Я применил патч вручную — тест падает на первой записи: `ENOENT …/nested/ledger.json` (файла ещё нет, потому что запись отложена) | | AC4 (`--ledger` без `--changed` → код 2, внятное сообщение) | `#481 AC4` (spawnSync реального CLI) | Убрал вручную блок отказа в `main()` — тест падает: `1 !== 2` (без отказа код возврата другой) | | AC5 (`validate.yml`: restore по шарду с `restore-keys`, `save` `if: always()`, уникальный на прогон ключ, `--ledger` в команде) | `test/validate-workflow.test.mjs`, тест «журнал свидетелей changed_mutants…» | Убрал вручную `if: always()` из шага `Сохранить журнал свидетелей` — тест падает: `красный или отменённый шард обязан сохранить уже пойманное` | | AC6 (структурная реконструкция #480: отбор по диффу не менялся, сужение — только `splitByLedger` по отпечатку, версия-инвариантность — следствие AC1) | ревью кода (метод согласован спек-ревью r2) | Прочитано: `selectChangedMutants`/`guardFiles` (#475) в диффе не изменены (сравнение `git diff` — ноль строк). Числа автора (`134→2`, `113 из 113`) — иллюстрация, не пересчитывались; структурный вывод подтверждён чтением, не цифрами | | AC7 (три негативных свидетеля, каждый «поймано 1 из 1») | `node scripts/mutation-gate.mjs --id=` по каждому из трёх | `ledger-records-escaped` → 1/1, `ledger-version-sensitive` → 1/1, `ledger-written-at-end-only` → 1/1. Все три реально приводят к красному тесту при обратном патче (проверено запуском, не чтением) | Примечание к AC7/поиску по `ledger-records-escaped`: у этого мутанта два патча (один в `main()`, второй в `splitByLedger`). Патч в `main()` в изоляции тесты `#481 AC2`/`AC3` не красит (они не вызывают `main()`); ловит его именно второй патч — расширение сравнения в `splitByLedger` (`|| mutant.id in ledger.caught`), которое `#481 AC2` красит немедленно. Проверил оба патча по отдельности и вместе, итог совпадает с официальным прогоном — мутант пойман, находка не подтвердилась. ## Находки Нет High и Medium. Два Low, оба сняты без правки (в рамках лёгкого трека и корректности остального изменения не блокируют): 1. **Low.** AC2 задокументирован в ТЗ доказательством «unit на функцию отбора + CLI на фикстуре», но в диффе — только unit на `splitByLedger`; CLI-интеграция `main()` (чтение журнала → отбор → запись) не покрыта отдельным тестом. Я воспроизвёл интеграцию вручную на реальном реестре (см. таблицу AC2 выше) и она работает верно, поэтому AC для этого раунда доказан исполнением, а не находка блокирует. Снимаю с записью: будущая правка `main()` (например, случайная перестановка `run`/`skipped`) не будет поймана ни одним из текущих тестов — стоит закрыть отдельным маленьким CLI-тестом на фикстуре при первой же следующей правке этого файла, не обязательно прямо сейчас. 2. **Low.** `scripts/source-fingerprint.mjs`: над `withoutProductVersion` остались рядом два JSDoc-блока — старый (описывающий прежнее инлайновое имя отбора, #245) и новый (#481); только второй, ближайший, будет прочитан инструментами документации, первый становится сиротой. Плюс `productVersion` экспортирован, но нигде за пределами этого файла не импортируется — сейчас не используется extern. Ни то, ни другое не влияет на поведение или гейты; снимаю без правки, чисто косметическое. ## Что проверено и корректно - `witnessFingerprint`: сортировка файлов детерминирована, JSON.stringify объявления мутанта включён в хеш, нормализация версии — через общий `withoutProductVersion` (не дублирует логику `visualFingerprint`, а `visualFingerprint` теперь сам через неё же — рефактор без изменения поведения, отпечаток скриншотов обновился только из-за самоссылки корпуса, не из-за смены алгоритма). - `readLedger`: отсутствующий/битый/чужой-схемой файл → пустой журнал («гонять всё») — три случая закрыты тестом AC3 и вручную перепроверены. - `recordCaught`/`splitByLedger`: инкрементальная запись, сравнение именно отпечатка (не факта присутствия id) — оба свойства пойманы негативными пробами. - `main()`: `--ledger` без `--changed` — код 2; `--ledger` с `--changed` — сужает уже отобранный по диффу набор, не расширяет; при пустом плане после журнала — явное сообщение и код 0, не ошибка. - Полный прогон (`mutation-gate.yml`, `--check`, `--id`), локальный сценарий (`pre-push-gate.mjs`, `--changed` без `--ledger`) — журнал не трогают, подтверждено чтением вызовов (`grep -rn "mutation-gate.mjs"` по workflow и scripts), совпадает с п.5 ТЗ «что не меняется». - `validate.yml`: `cache/restore` до шага прогона, `cache/save` после, с `if: always()`; ключ уникален на `run_id`, `restore-keys` — префикс по шарду. Порядок шагов и присутствие обоих действий проверены и тестом, и чтением самого файла. - Трейлеры, разбиение на коммиты по классам (B/C/D), `User-Visible: no` без правок changelog — верно. - Ни один файл `src/**` или `custom_components/**/*.py` не тронут — гейты golden/smoke/backend/invariants обоснованно не требуются этим диффом. ## Вердикт Вердикт: зелёный · заход r1 · блокирующих циклов 0/2 · High: 0 · Medium: 0 → в задаче Документ: docs/reviews/CODE-REVIEW-481-r1.md --- ## Материал раунда - Ветка: `issue/481-mutation-ledger`, коммит `0f36eb1c1cc6` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `0e080a48f396df7d234779dc0f41146e50cc2180` ``` git log --all --format='%H %T' | grep 0e080a48f396 ```