diff --git a/docs/reviews/CODE-REVIEW-462-r1.md b/docs/reviews/CODE-REVIEW-462-r1.md new file mode 100644 index 00000000..d56237c5 --- /dev/null +++ b/docs/reviews/CODE-REVIEW-462-r1.md @@ -0,0 +1,324 @@ +# CODE-REVIEW-462-r1 + +**Issue:** https://github.com/Matysh/houseplan-card/issues/462 +**Этап:** код-ревью (PROCESS.md §2.7), заход r1, блокирующих циклов израсходовано 0/4 +**Ревьюер:** Claude (роль «ревьюер кода», отдельная от роли «разработчик» — Codex) +**Материал:** `git log --oneline origin/dev..HEAD` (16 коммитов, все с трейлером `Issue: #462`) и +`git diff origin/dev...HEAD` на SHA `daccaf7ea54a01e13d4150cbd588893ad90a4672` — этот SHA совпадает +с текущим `HEAD` в момент вывода вердикта (проверено `git rev-parse HEAD` непосредственно перед +подведением итогов, §2.7 «вердикт привязан к SHA»). +**ТЗ:** `docs/specs/462-card-resource-registration.md`, ревью ТЗ зелёное на r3 +(`docs/reviews/SPEC-REVIEW-462-r3.md`), AC1–AC13. + +## 1. Скоуп диффа + +74 файла, +4739/−450 (единственный `User-Visible: yes` коммит `fcf0ce80`, остальные 15 — +`User-Visible: no`, тесты/документация/сгенерированные бандлы). Функционально: + +- **Backend:** новый модуль `custom_components/houseplan/frontend_registration.py` (659 строк) — + типизированный outcome регистрации Lovelace-ресурса, lifecycle retry на 1 секунду, + одноразовое `persistent_notification`, честный System Health; `__init__.py` и `system_health.py` + переведены на него; `manifest.json` получил `after_dependencies: ["lovelace"]`. +- **Frontend:** новый чистый контроллер `src/version-recovery.ts` (331 строк, без DOM/Lit/HA) + + адаптер `src/version-recovery-card.ts` (223 строки) + интеграция в `src/houseplan-card.ts` + (89 строк диффа: баннер в `_renderRoot`, единая точка `_openMoreInfo`, `_syncVersionRecovery` + на всех жизненных циклах). +- **Документация:** README/USER-GUIDE EN+RU — развилка Storage/YAML `resource_mode`/legacy + `mode: yaml`; статический parser в `scripts/check-docs.mjs`; оба changelog. +- **Тесты:** 24 backend-теста (`tests_backend/test_ha_frontend_registration.py`), 3 новых + frontend-unit-файла, `demo/smoke_version_recovery.mjs` (405 строк), 3 golden-сценария, 14 новых + мутантов в `scripts/mutation-gate.mjs`. + +Продуктовое поведение подтверждено проверкой на реальном исходнике HA (см. §5) — задача не +меняет раздачу бандла/кэш-бастинг, только надёжность регистрации и явную диагностику. + +## 2. Как проверялось — таблица гейтов + +| Гейт | Прогнан | Результат | +|---|---|---| +| `npx tsc --noEmit`, `npm test`, `npm run build` + сверка 3 копий бандла | Нет, переиспользован | Validate зелёный на точном `HEAD` `daccaf7e`: https://github.com/Matysh/houseplan-card/actions/runs/33969641826 | +| `node scripts/check-docs.mjs` (diff трогает `src/**`) | Нет напрямую, переиспользован | Часть того же зелёного Validate (job `docs`) | +| `npm run golden:verify` (диффа рендера AC12) | Нет, переиспользован | Часть зелёного Validate (job `golden`); 3 новых baseline приняты коммитом `daccaf7e` с трейлерами `Release: v1.72.0-beta.5` + `Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33969189246` | +| `python -m pytest tests_backend -q` | Нет, переиспользован | Часть зелёного Validate (job `backend`); локально pytest/homeassistant не установлены в этой ревью-сессии (см. §6) | +| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | **Да** | 98 «прямых совпадений» — разбор ниже (§4.3), большинство ложные | +| `node demo/smoke_version_recovery.mjs` | **Да** (через мутанта, чистый прогон) | зелёный — `ok чистый прогон: node demo/smoke_version_recovery.mjs` | +| `node scripts/mutation-gate.mjs --id=<14 мутантов #462>` | **Да**, 10 из 14 | 10/10 покраснели корректно (см. таблицу §5); 4 backend-мутанта не прогнаны локально (нет `pytest`/`homeassistant` в этой ревью-песочнице — дорогой гейт, разрешено читать вместо повторного прогона, §2.7) | +| Чтение исходника HA (`homeassistant==2024.6.0`, минимально поддерживаемая версия) | **Да** | скачан `pip download homeassistant==2024.6.0`, подтверждено наличие `ConfigEntry.async_create_background_task(hass, target, name, eager_start=True)` — закрывает открытый вопрос по AC3 (см. §5) | +| Инварианты модели (`npm run invariants`) | Не прогнан | diff не трогает геометрию/`layout`/`marker.space`/`open_spans` — неприменимо | +| Performance-профили | Не прогнан | не названы в AC; единственное влияние на перф — бюджет бандла, уже пересчитан и задокументирован автором в `scripts/bundle-budget.mjs` (см. §7) | + +## 3. Находки + +**High: 0. Medium: 0 (в скоупе задачи и вне его). Low: 3, ни один не блокирует, оставлены с +записью ниже.** + +| # | Файл:строка | Что | Severity | Решение | +|---|---|---|---|---| +| L1 | `src/version-recovery-card.ts:176` | `viewOnly` включает `host._config?.kiosk === true`, хотя `VersionRecoveryController._reconcile()` (`src/version-recovery.ts:242`) уже сам не вызывает `isVersionReloadSafe()` вне kiosk-ветки. Задвоение безопасное (belt-and-suspenders), но при будущем рефакторинге кто-то может решить, что `viewOnly` — единственный источник kiosk-гейта, и убрать внешнюю проверку по ошибке | Low | Оставлено с записью; не влияет на корректность текущего кода | +| L2 | `src/styles/base.styles.ts` (правила `.version-recovery*`, `:host(houseplan-card)`) | Стили лежат в общем `baseStyles`, который импортирует и `houseplan-space-card` (`src/space-card.ts:9,932`). Селектор `:host(houseplan-card)` корректно не матчится на space-card, поэтому визуальной утечки нет — это чистый вопрос лишних байт в бандле space-card | Low | Оставлено; `test/version-recovery-card-contract.test.mjs` уже фиксирует границу селектора | +| L3 | `custom_components/houseplan/frontend_registration.py:537` | `entry.async_on_unload(state.cancel)` регистрируется при каждом вызове `async_setup_frontend_registration`, включая гипотетический повторный вызов без промежуточного unload — список on-unload callbacks мог бы расти. `cancel()` идемпотентен, и повторный вызов достижим только при нарушении контракта HA (setup без unload между вызовами), что не воспроизводится ни в одном тесте и не считается реальным путём в текущем HA lifecycle | Low | Оставлено с записью; не воспроизводится, не блокирует | + +Ни один из этих трёх пунктов не меняет вывод AC и не варьирует поведение, наблюдаемое +пользователем — вердикт по ним: **правится не будет, оставлены как документированные +наблюдения** (решение ревьюера, §2.7 «Low либо правится, либо снимается с записью»). + +Отдельно отмечаю (не находка, наблюдение по §2.4/2.6 «скоуп не расширяется»): коммит `65d38b24` +(`fix: keep continuity cache read-only until load`, `Issue: #462`, `User-Visible: no`) добавляет +guard `!this._loadOk` в `_seedHiddenDevices` и `_syncAreaRelocations` +(`src/houseplan-card.ts:3958,5120-5121`) — защита от того, что continuity-cache-кадр, +нарисованный до завершения авторитетной загрузки config+layout, мог бы вызвать деструктивную +запись (перезаписать серверную фильтрацию или удалить валидный snapshot area-relocation) поверх +устаревшего состояния. Это тот же инвариант `_loadOk`, от которого зависит новый предикат +`initialFrameSettled` (`version-recovery-card.ts:155`), поэтому чинить его в рамках этой же задачи +оправдано — это не отдельная попутная правка, а условие корректности самого AC8. Пользовательски +видимый эффект (защита от редкой потери непримечательного состояния при холодном старте) реален, +но обнаружен как превентивное упрочнение, а не как воспроизведённый дефект; `User-Visible: no` +для него — решение автора на грани, но не искажение фактов и не блокирует. + +## 4. AC-по-AC разбор + +### 4.1 Backend (AC2, AC3, AC4, AC5, AC11) + +Читал `custom_components/houseplan/frontend_registration.py` целиком (659 строк) плюс +`tests_backend/test_ha_frontend_registration.py` (24 теста) и `system_health.py`/`__init__.py` +диффы. Параллельно второй читатель (независимый субагент) прошёл тот же материал — +расхождений в выводах нет. + +- **AC2 (честный outcome).** `async_register_lovelace_resource` (строки 191–265) различает все + 7 состояний; existing-запись обновляется без дубля (`_async_remove_duplicate_resources`, + 164–189, тест `test_registration_keeps_one_canonical_resource_and_removes_duplicates:114-130`); + отсутствующий файл никогда не даёт успешный loader (`async_setup_frontend_registration:518-535, + 562-564`, тест `test_ha_setup.py::test_missing_frontend_bundle_does_not_skip_backend_setup:54-83` + проверяет `resource_status == "not_attempted"`, `loader == "none"`). **Держится.** +- **AC3 (retry без двойного loader).** `_schedule_retry` (475–504) всегда ставит фиксированную + секундную задержку через `async_call_later` внутри колбэка `async_at_started`, даже когда HA уже + running (в этом случае `async_at_started` вызывает колбэк синхронно, но `async_call_later` + всё равно откладывает retry) — подтверждено и чтением, и тестом + `test_already_running_setup_still_uses_the_fixed_one_second_delay:338-390`. Отмена + start-listener/timer/retry-task привязана к `entry.async_on_unload(state.cancel)` (537), + `cancel()` (92–105) корректно останавливает все три; все три окна отмены (до старта, во время + задержки, во время задачи) явно проверены в `test_retry_lifecycle_handles_are_cancelled:456-551` + тремя «поколениями» entry. Удаляется только собственный versioned URL setup'а + (`_owned_fallback_urls`, 130–137 + `_remove_owned_fallback_url`, 304–327), чужой/пользовательский + URL никогда не трогается (`test_preexisting_exact_fallback_is_used_but_never_claimed_or_removed: + 670-720`). Повторный вызов `_schedule_retry` из одного-единственного места (591) — «второй retry + не наступает» верно по построению, а не только по тесту. **Держится.** +- **AC4 (одноразовое notification).** Флаг пишется в `entry.data` только после того, как + `persistent_notification.async_create` вернулся без исключения (`_async_create_reload_notice: + 350-433`), гонка «fallback-путь vs retry-завершение» серилизована `state._notice_lock` (360); + тест `test_reload_notice_is_localized_and_persisted_once:723-770` дважды вызывает функцию и + проверяет ровно одно создание. Missing file/static failure не расходуют флаг (365–372, + `pending_frontend`). Uninstall dismiss-ит best-effort (631–634). **Держится.** +- **AC5 (System Health).** `system_health.py:37-75` кладёт все восемь полей §10 поверх + существующей статистики плана; параметризованный тест + `test_system_health_reports_frontend_registration_matrix:1150-1335` покрывает + missing/success/pending/yaml/exception и отдельно проверяет, что старые поля (`config_rev`, + `spaces`, …) не исчезли (1314–1335). **Держится.** +- **AC11 (downgrade/compatibility).** `websocket_api.py`, отдающий `integration_version`, этим + диффом не тронут (нет в списке изменённых файлов); существующий `test_ha_websocket.py` + не модифицирован. `test_ha_setup.py` получил один новый тест, остальные не изменены. + **Держится** (проверено чтением, не повторным прогоном полного backend-набора — см. §6). + +**Разрешённая неопределённость.** Backend-субагент отметил открытый вопрос: доступен ли +`ConfigEntry.async_create_background_task` на минимально поддерживаемой HA 2024.6? Я скачал +`homeassistant==2024.6.0` (`pip download`) и прочитал `homeassistant/config_entries.py` из wheel — +метод существует с сигнатурой `(hass, target, name, eager_start=True)`, ровно как используется в +`frontend_registration.py:494-497`. Вопрос закрыт, разрыва по минимальной версии нет. + +`after_dependencies: ["lovelace"]` (`manifest.json:4-6`) — техническая избыточность поверх уже +существующей жёсткой цепочки `houseplan → frontend → lovelace` (проверено: `dependencies` в +манифесте houseplan уже содержит `frontend`, а `frontend`'s собственный манифест в +HA 2026.8.3 содержит `lovelace` как hard dependency) — это defense-in-depth, не фикс +несуществующего разрыва, и явно назван так в ТЗ §22 п.3 (не выдан за факт). + +### 4.2 Frontend (AC6–AC10, AC12) + +Читал `src/version-recovery.ts` и `src/version-recovery-card.ts` целиком, диффы +`src/houseplan-card.ts`/`base.styles.ts`/i18n, три тестовых файла и +`demo/smoke_version_recovery.mjs` полностью. Второй независимый читатель (субагент) прошёл тот же +материал параллельно — расхождений нет. + +- **AC6 (точное сравнение).** `normalizeRuntimeVersion`/`compareRuntimeVersions` + (`version-recovery.ts:69-83`) трактуют не-строку/пустую/whitespace-строку как `unknown`, что + стирает устаревший mismatch, а не оставляет его (мутант + `version-recovery-treats-unknown-as-mismatch` прогнан лично мной — покраснел). Матрица + §11.1 реализована в `_reconcile()` (233–260) построчно. **Держится.** +- **AC7 (обычный режим никогда не reload сам).** Вне kiosk `_reconcile()` (242–247) никогда не + вызывает `_armTimer()`; единственный путь к `reload()` — `renderVersionBanner`'s + `event.isTrusted && controller.hasCurrentMismatchNotice` (`version-recovery-card.ts:195-197`). + Мутант `version-recovery-auto-reloads-ordinary-view` прогнан лично мной — покраснел. + **Держится.** +- **AC8 (kiosk safety).** Все 10 полей `isVersionReloadSafe` (`version-recovery.ts:99-110`) + замаплены на конкретное состояние карточки в `cardVersionReloadSafetySnapshot` + (`version-recovery-card.ts:151-186`). `surfacesIdle` переиспользует существующий + `_editorSecondaryDialogBlocked` (`houseplan-card.ts:9017-9027`, покрывает `_dangerConfirm`, + `_importDialog`, `_backupImportDialog`, `_kioskDialog` и ещё 15 диалогов) плюс отдельно + `_partitionDeleteDialog`/`_roomDeleteDialog`/`_backdropGuard`/`_vacFit`. `_openMoreInfo` + (`houseplan-card.ts:5603-5619`) — единственная точка входа для всех вызовов (проверено: все + 5 call site — pointer/`_ctxDevice`, keyboard через `_clickDevice`, vacuum-иконка, info-card + кнопки — сведены к этой функции), продлевающая `_cyclePausedUntil` безусловно при + `this._kiosk`, независимо от модальности вызова. `_tick()` (278–306) перечитывает + `hooks.safety()` заново на каждом 250-мс тике — не залипающий снапшот, TOCTOU-окна нет (проверка + и `claimReloadTarget` происходят синхронно в одном тике). Четыре мутанта + (`version-recovery-ignores-editor-state`, `-ignores-dialog-state`, + `-ignores-pending-config-write`, `-ignores-interaction-pause`) прогнаны лично мной — все + покраснели. **Держится.** +- **AC9 (защита от reload-loop).** `attemptedTarget`/`claimReloadTarget` + (`version-recovery.ts:117-147`) читают и пишут один и тот же ключ `sessionStorage`; target + durable записан ДО вызова `reload()` (293–298, подтверждено мутантом + `version-recovery-marks-target-after-reload` — покраснел лично у меня); сохранённый target + уважается новым инстансом (мутант `version-recovery-ignores-stored-target` — покраснел лично у + меня); недоступный `sessionStorage` — fail-safe в баннер (`attemptedTarget`/`claimReloadTarget` + catch-блоки, 121-127/138-147). **Держится.** +- **AC10 (граница full/space).** `test/version-recovery-card-contract.test.mjs:16-31` статически + подтверждает отсутствие импорта version-recovery в `src/space-card.ts` (grep подтвердил + 0 совпадений); браузерно — `demo/smoke_version_recovery.mjs:349-362` + (`staticOwnsNoController: !('_versionRecovery' in staticCard)`). **Держится.** +- **AC12 (View/touch/a11y/визуальная стабильность).** `ordinaryStageDoesNotMove` (smoke) — баннер + не двигает stage bbox; `role=status`/`aria-live=polite`/`aria-atomic=true` без повторного + объявления благодаря early-return в `_showBanner` (`version-recovery.ts:308-310`); кнопка + ≥44×44 CSS px (`buttonMin44` в smoke); `prefers-reduced-motion` отключает анимацию + (`_hideBanner`, 320-330 + `base.styles.ts`). Подавление тоста #353 — точное условие + `info.terminal && this._versionRecovery.hasCurrentMismatchNotice` + (`houseplan-card.ts:717,747`), не более широкое: non-terminal и terminal-без-баннера всегда + показывают тост (проверено и unit-веткой, и smoke-сценариями + `lazyToastWithBanner`/`terminalToastWithoutBanner`). Три golden-сценария (desktop/touch/kiosk + after-attempt) приняты в `daccaf7e` со ссылкой на прогон CI. **Держится.** + +### 4.3 Документация (AC1) и артефакты сборки (AC13) + +`scripts/check-docs.mjs` (диф +72 строки) статическим парсером требует ровно два снепшота +(`resource_mode: yaml` современный + помеченный legacy `mode: yaml`), запрещает плоский +top-level `resources:`, и требует оба hard-reload shortcut во всех четырёх документах — сверил +построчно с README.md/README.ru.md/USER-GUIDE.{md,ru.md}, текст идентичен ожидаемому контракту. +Мутант `resource-docs-flatten-current-yaml` прогнан лично мной — покраснел. **Держится.** + +AC13: три бандл-дерева (`dist`, `custom_components/.../frontend`, демо-стенд) синхронны — это +часть зелёного Validate на `daccaf7e`, отдельно не перепроверял. i18n: en/ru/de/fr на фронтенде +(`src/i18n/*.json`) и en/ru/de/fr на бэкенде (`strings.json`+`translations/*.json`) — сверил +построчно, ключи присутствуют во всех восьми файлах без исключений. + +## 5. Таблица «чем краснеет» (защитные AC, §2.7) + +| AC | Чем доказан | Чем краснеет | Прогнал сам? | +|---|---|---|---| +| AC1 (docs contract) | `node scripts/check-docs.mjs` | мутант `resource-docs-flatten-current-yaml` (плоский `resources:` вместо вложенного) | Да — покраснел | +| AC3 (retry без delay) | `pytest -k retry_waits_one_second` | мутант `frontend-registration-retries-without-delay` (снята фиксированная секундная задержка) | Нет (нет pytest/HA локально) — подтверждено чтением assertion `timer["delay"] == 1.0` дважды независимо (я + субагент) | +| AC3 (retry вообще не наступает) | тот же тест | мутант `frontend-registration-skips-retry` (убран вызов `_schedule_retry`) | Нет — чтением: assertion `"callback" in started` | +| AC3 (unload-race) | `pytest -k retry_lifecycle_handles_are_cancelled` | мутант `frontend-registration-is-not-unload-bound` (убран `entry.async_on_unload`) | Нет — чтением: assertion `len(before_start_entry.unload_callbacks) == 1` | +| AC4 (флаг персистится) | `pytest -k reload_notice_is_localized_and_persisted_once` | мутант `frontend-reload-notice-forgets-persisted-flag` | Нет — чтением: assertion `len(created) == 1` после второго вызова | +| AC6 (unknown не путается с mismatch) | `node --test --test-name-pattern="malformed values stay unknown" test/version-recovery.test.mjs` | мутант `version-recovery-treats-unknown-as-mismatch` | **Да — покраснел** | +| AC6 (config/get владеет capability-adoption до соседних отказов) | `node demo/smoke_version_recovery.mjs` | мутант `version-recovery-delays-config-capability-adoption` | **Да — покраснел** (бандл пересобран, полный дорогой прогон) | +| AC7 (обычный режим не reload) | `node --test --test-name-pattern="ordinary mode always" test/version-recovery.test.mjs` | мутант `version-recovery-auto-reloads-ordinary-view` | **Да — покраснел** | +| AC8 (editor guard) | `test/version-recovery-card.test.mjs` (`card adapter maps editor state`) | мутант `version-recovery-ignores-editor-state` | **Да — покраснел** | +| AC8 (dialog guard) | тот же файл (`maps blocking surfaces`) | мутант `version-recovery-ignores-dialog-state` | **Да — покраснел** | +| AC8 (config-write guard) | тот же файл (`maps config writes`) | мутант `version-recovery-ignores-pending-config-write` | **Да — покраснел** | +| AC8 (interaction-pause guard) | тот же файл (`maps interaction pause`) | мутант `version-recovery-ignores-interaction-pause` | **Да — покраснел** | +| AC9 (target записан до reload) | `test/version-recovery.test.mjs` (`marks the exact target before one reload`) | мутант `version-recovery-marks-target-after-reload` | **Да — покраснел** | +| AC9 (сохранённый target уважается) | тот же файл (`same target is once per tab`) | мутант `version-recovery-ignores-stored-target` | **Да — покраснел** | + +10 из 14 мутантов прогнаны лично мной командой `node scripts/mutation-gate.mjs --id=` — +каждый показал `ok : тест покраснел, как обязан`. 4 backend-мутанта относятся к дорогому +гейту (`pytest`/`homeassistant`, недоступны в этой ревью-песочнице) — для них по правилу §2.7 +достаточно чтения: я лично прочитал целевые assertion'ы и подтвердил точное попадание в +мутированную строку (см. §4.1); независимый второй читатель (субагент) пришёл к тем же выводам +по тем же строкам. + +## 6. Одно число — один источник + +Версии, показанные в баннере (`version_mismatch.frontend`/`.backend`), и версия, используемая +для ключа `sessionStorage`-guard'а, читаются из **одного** вызова: +`compareRuntimeVersions(normalized.frontendVersion, normalized.backendVersion)` +(`version-recovery.ts:215-222`), где `normalized.backendVersion` — результат +`normalizeRuntimeVersion(capabilities.integration_version)` из того же `adoptCardConfigCapabilities` +(`version-recovery-card.ts:52`), что и адаптирует capability. Второго независимого пути получения +`integration_version` в новом коде нет — старое присвоение `_haIntegrationVersion` внутри +`_reloadLayoutOnly()` (`houseplan-card.ts:4914-4917` до диффа читало `resp?.integration_version` +из ответа `houseplan/layout/get`) **удалено этим диффом** (дифф `-4879,8 +4914,6` вычёркивает +строки `this._haIntegrationVersion = typeof resp?.integration_version === 'string' ? ...`), +то есть ранее существовавший второй источник (из `layout/get`) закрыт, а не оставлен рядом с +новым (`config/get` через `adoptCardConfigCapabilities`). Один источник подтверждён. + +## 7. Что проверено и признано корректным + +- Все 13 AC ТЗ разобраны по коду (см. §4), для защитных — таблицей «чем краснеет» (§5). +- Idempotency повторного `setup`/`reload`/`unload` — тестами `test_repeated_setup_updates_once_ + then_reuses_canonical_resource`, `test_fallback_survives_unload_reload_and_registry_ + reconciliation`, тремя «поколениями» в `test_retry_lifecycle_handles_are_cancelled`. +- `_safe_error` (`frontend_registration.py:108-110`) — только `phase:ExceptionClassName`, ни + сообщения, ни пути, ни стектрейса; проверено по всем вызовам (`async_register_lovelace_ + resource`, `_add_fallback`, `_remove_owned_fallback_url`, `async_setup_frontend_registration`). +- Отсутствующий bundle не валит setup (`test_missing_frontend_bundle_does_not_skip_backend_ + setup`), sweep/repairs продолжают исполняться. +- Uninstall убирает все записи с базовым URL, но никогда — чужой/пользовательский URL + (`test_uninstall_removes_all_base_url_entries_fallback_and_notice`, + `test_preexisting_exact_fallback_is_used_but_never_claimed_or_removed`). +- `single_config_entry: true` в манифесте (не менялся этим диффом) исключает + многократную конкурентную регистрацию из разных entries — предпосылка «второй retry не + наступает» не имеет обходного пути через второй entry. +- Бюджет бандла (`scripts/bundle-budget.mjs`) пересчитан и задокументирован: потолок + 298 500→301 000, бюджет 300 000→301 066, измеренный факт 300 090 Б gzip (+2 587 Б за + eager version-recovery controller), 976 Б запаса; `test/bundle-assets.test.mjs` больше не + хардкодит число, читает константу. +- golden: 3 новых сценария (`version-mismatch-{desktop-dark-en,touch-light-ru, + kiosk-attempted-dark-de}`) приняты на полном Linux CI-артефакте (`Baseline-Reviewed` ссылка + в трейлере), `demo/golden/harness.mjs` до снятия кадра явно бросает, если целевая версия + баннера/`sessionStorage` не совпала со сценарием — фейл-фаст на рассинхрон, а не тихий кадр. +- i18n en/ru/de/fr полны на обеих сторонах (frontend `src/i18n/*.json`, backend + `strings.json`+`translations/*.json`), `test/i18n.test.mjs` учитывает новый allowed-equal key. +- Оба changelog обновлены в том же коммите, что и поведение (`fcf0ce80`, `User-Visible: yes`). +- Все 16 коммитов диапазона несут `Issue: #462`; единственный `User-Visible: yes` коммит содержит + правки в обоих changelog. + +## 8. Чего не проверял и почему + +- **Полный локальный прогон `tsc`/`npm test`/`npm run build`/`golden:verify`/ + `pytest tests_backend`** — не прогонял: они дешёвые по регламенту, но у меня уже есть зелёный + Validate ровно на этом SHA (`daccaf7e`), и правило «дешёвые гейты уже подтверждены» разрешает их + не перегонять в этом раунде. +- **4 backend-мутанта (`frontend-registration-*`, `frontend-reload-notice-*`)** — не прогнаны: + в этой ревью-песочнице нет `pytest`/`homeassistant` (`.venv-backend` не создаётся вне cloud-agent + startup script, см. AGENTS.md), а мутация-гейт для них — дорогой (пересборка + pytest). По §2.7 + для дорогого backend-гейта разрешено читать assertion вместо повторного прогона — сделано дважды + независимо (я и субагент), см. §5. +- **97 из 98 «прямых совпадений» `smoke-select.mjs`** — не прогонял. Инструмент матчит по общим + символам (`_mode`, `_zoom`, `_stageEl`, `_config`, …), но подавляющее большинство совпадений — + побочный эффект того, что `VersionRecoveryCardPort` (`version-recovery-card.ts:71-136`) объявляет + ~40 УЖЕ существующих полей карточки как readonly-типы для предикатов безопасности, не меняя их + семантику ни в одной строке. Ни один из этих смоков (decor/furniture/kiosk/resize/…) не проверяет + код, который этот дифф действительно меняет — реальное изменение поведения ограничено + `_openMoreInfo`, `_renderRoot`, `_syncVersionRecovery` и новыми модулями. Прогон всех 98 был бы + предрелизным набором, а не гейтом ревью (PROCESS.md §8). Единственный по-настоящему целевой смок + — `smoke_version_recovery.mjs` — прогнан (см. §2). +- **`npm run invariants`** — не прогонял: diff не трогает геометрию, `layout`, `marker.space`, + `open_spans` ни в одной строке. +- **Performance-профили** — не прогонял: не названы в AC; единственное влияние на перф — + бюджет бандла, который уже пересчитан и обоснован автором с измеренным фактом (см. §7). +- **Ручное тестирование в браузере** — не проводилось (в этом процессе его нет вообще, код-ревью + заменяет его, PROCESS.md §2.7). + +## 9. Вывод + +Все 13 AC либо доказаны автотестом, чью способность падать я лично подтвердил (10 мутантов из 14 +для #462 прогнаны лично, плюс независимая пара глаз на оставшихся 4), либо разобраны по коду с +явной пометкой «проверено чтением» там, где повторный прогон дорогого гейта не входит в объём +ревью. Три Low-находки не меняют вывод ни по одному AC и оставлены с записью. High и Medium +находок нет — ни в скоупе, ни вне его. + +**Вердикт: зелёный.** + +--- + + + +## Материал раунда + +- Ветка: `issue/462-card-resource-registration`, коммит `daccaf7ea54a` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. +- Дерево материала: `0caccec714620782910b4f0c0fc60ddc0f769561` + ``` + git log --all --format='%H %T' | grep 0caccec71462 + ``` +- ТЗ `docs/specs/462-card-resource-registration.md`, блоб `d3620eab48950255aa91240cd70ec3ed1edfd756` + ``` + git log --all --find-object=d3620eab48950255aa91240cd70ec3ed1edfd756 -- docs/specs/462-card-resource-registration.md + ```