# 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 ```