docs: review document for #462

Issue: #462
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-05 14:06:29 +00:00
parent daccaf7ea5
commit fc0c616698
+324
View File
@@ -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=<id>` —
каждый показал `ok <id>: тест покраснел, как обязан`. 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
находок нет — ни в скоупе, ни вне его.
**Вердикт: зелёный.**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `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
```