# CODE-REVIEW #32 · r1 — единое подтверждение опасных действий - Issue: https://github.com/Matysh/houseplan-card/issues/32 - Этап: code (PROCESS.md §2.7) - Заход: r1 · блокирующих циклов израсходовано 0 из 4 - Материал: `git log --oneline origin/dev..HEAD`, `git diff origin/dev...HEAD` - ТЗ: `docs/specs/032-unified-danger-confirmation.md` (spec-review зелёный на r2, SHA `206bcd1a`, документы `docs/reviews/SPEC-REVIEW-32-r{1,2}.md`) - Это первый раунд код-ревью — предыдущего кода-раунда нет, разбор полный, разделы «Унаследовано из r0» и «Закрытие r0» не нужны. ## Скоуп диффа `origin/dev...HEAD`, 57 файлов, ключевые продуктовые: - `src/danger-confirm.ts` (новый) — `HpConfirmController`, replace-not-queue, token-based resolve. - `src/hp-confirm.ts` (новый) — presentation поверх `hp-dialog`. - `src/houseplan-card.ts` — controller-owner, host API `_confirmDanger`/ `_cancelDangerConfirm`, cancel на смене space/mode/route/disconnect, unlock call site. - `src/houseplan-editor-runtime.ts`, `src/houseplan-onboarding-runtime.ts` — миграция 7 из 8 call sites (draft whole/segment, marker, plan×2, space×2). - `src/i18n/{en,ru,de,fr}.json` — новые title/body ключи. - `src/styles/dialogs.styles.ts` — стили `.danger-confirm-*`. - `test/danger-confirmation.test.mjs` (новый), правки `test/render-device-snapshot.test.mjs`, `tsconfig.test.json`. - `demo/smoke_danger_confirmation.mjs` (новый), правки `demo/smoke_lock_action.mjs`, `demo/smoke_lock_invariant.mjs`, `demo/smoke_saved_plans.mjs`. - Документация: `docs/ARCHITECTURE.md`, `docs/USER-GUIDE.ru.md`, оба CHANGELOG, сгенерированное дерево бандла (dist + custom_components + demo/srv). Основа сверена читением: все 8 call sites §4.1 ТЗ найдены в диффе как `await this._confirmDanger(...)` / `await this.host._confirmDanger(...)`; прямых вызовов `confirm(` в `src/**` не осталось (перепроверено `rg` и собственным прогоном `test/danger-confirmation.test.mjs`). ## Как проверялось Зелёного Validate на SHA `7b363caa` нет — все гейты ниже прогнаны мной лично в рабочей копии на этом SHA. ### Дешёвые гейты (прогнаны) | Команда | Результат | |---|---| | `npx tsc --noEmit` | чисто, без вывода | | `npm test` | `tests 1652 · pass 1651 · fail 0 · skipped 1` | | `npm run bundle:sync` | build ok; `git status --porcelain` после — пусто (три дерева синхронны) | | `npm run bundle:budget` | `initial View: 285355 B gzip (budget 300000 B, headroom 14645 B)` — совпадает с заявкой автора (+1300 B к baseline 284055, ≤3 KiB по AC-13) | | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` | | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | `Новых any нет` (379 добавленных строк в 6 файлах) | `npm run invariants` не прогонял: дифф не трогает рёбра/толщину/`layout`/ `marker.space`/`open_spans` — только диалоговый слой и call sites, геометрия не затронута. `npm run golden:verify` не прогонял: новый диалог не входит в golden-матрицу, и diff не показывает изменения существующего baseline (только служебные скриншоты документации, см. ниже). `python -m pytest tests_backend -q` не прогонял: `custom_components/**/*.py` не тронут (только сгенерированное `frontend/*`). Performance-профили не прогонял: не названы в AC, чувствительные к перфу пути не затронуты. ### Browser smoke — выбор и результат `node scripts/smoke-select.mjs --base origin/dev --head HEAD`: матрица 209 смоков, 39 «прямое совпадение», 81 «слабая связь» (остальное неопределённость). Прямые совпадения в основном объясняются тем, что дифф трогает общие поля хоста (`_mode`, `_markerDialog`, `_spaceDialog`, `_physicalDialog`, `_curSpaceCfg`, `_openingInfo`, `_commitSpace`) в функциях-обёртках (`_setMode`, `_commitSpace`, `_editorSecondaryDialogBlocked`), а не потому что эти смоки проверяют подтверждение как таковое. Прогнал (актуальный контракт диалога — cancel/accept/replace/focus/touch, уже прогнанные автором плюс независимая перепроверка): `smoke_danger_confirmation`, `smoke_esc_dialogs`, `smoke_dialog_footer_width`, `smoke_toggle_confirmation`, `smoke_opening_binding`, `smoke_lock_action`, `smoke_lock_invariant`, `smoke_saved_plans`, `smoke_warm_dialogs`, `smoke_editor_tabs` — все 10 зелёные (полный JSON output эквивалентен заявке автора). Дополнительно прогнал прямые совпадения на `_physicalDialog`/`_openingInfo`, которых не было в списке автора, потому что ровно они называют мигрированные call sites (`_deleteDraftWhole`/`_deleteDraftSegment`, `_lockAction`): `smoke_free_walls`, `smoke_registryless_opening`, `smoke_inert_openings`, `smoke_open_passage`, `smoke_lazy_editor_chunk` (AC-10). Это вскрыло H1 (ниже): `smoke_free_walls` и `smoke_registryless_opening` падают. Разбирая причину, прошёлся по всем демо-смокам, которые вообще вызывают один из мигрированных методов (`grep -rl "_deleteMarker\|_deleteSpace()\|_deleteServerPlan\|_deleteDraftWhole\|_deleteDraftSegment\|_deletePhysicalSelection\|_lockAction(" demo/smoke_*.mjs` → 11 файлов) и отдельно все, что стабили `window.confirm` (`grep -rl "window\.confirm\s*=" demo/smoke_*.mjs` → 6 файлов), чтобы отделить не задетые (`lock`-only, `partition`/`column`-kind, blocked-путь) от реально сломанных. Результат — раздел «Находки» ниже. Остальные 29 прямых совпадений и все 81 слабых не прогонял: по чтению кода дифф трогает эти поля только через общий `_editorSecondaryDialogBlocked`/ `_cancelDangerConfirm` — они не меняют логику, которую эти смоки проверяют (room dialog, merge/split, tap confirm, vac calibration, палитра мебели и т.п.), а не потому что «выбор по теме» — их предмет прямо не пересекается ни с одним из 8 мигрированных call sites (подтверждено `grep` выше: только перечисленные 11 файлов вообще вызывают эти методы). ## Находки ### H1 — миграция ломает существующие смоки: 2 дают неверный вердикт, 3 виснут навсегда **Файлы:** `demo/smoke_free_walls.mjs`, `demo/smoke_registryless_opening.mjs`, `demo/smoke_binding_picker.mjs`, `demo/smoke_hidden_flag.mjs`, `demo/smoke_optional_space_model.mjs`. Задача заменяет синхронный `confirm()` на `await this._confirmDanger(...)`, который резолвится только реальным кликом по кнопке диалога. Дифф обновил под это ровно 3 существующих смока (`smoke_lock_action.mjs`, `smoke_lock_invariant.mjs`, `smoke_saved_plans.mjs` — видно в `git diff origin/dev...HEAD --stat`), но в дереве есть ещё 5 смоков, которые вызывают один из тех же восьми call sites и по-старому либо стабят `window.confirm = () => true` без взаимодействия с диалогом, либо не ждут промис вовсе. Воспроизведено исполнением, не догадкой: ``` $ node demo/smoke_free_walls.mjs FAILED (2): - deleteOnDraftRemovesWholeOutline: expected true, got false - invalidThicknessCreatesNothing: expected true, got false $ node demo/smoke_registryless_opening.mjs FAILED (1): - explicitInfoActionStillWorks: expected true, got false $ timeout 25 node demo/smoke_binding_picker.mjs Terminated # процесс не завершается сам, требуется kill $ timeout 25 node demo/smoke_hidden_flag.mjs Terminated $ timeout 25 node demo/smoke_optional_space_model.mjs Terminated ``` Причина видна в исходниках смоков: - `smoke_free_walls.mjs:13` ставит `window.confirm = () => true;`, затем синхронно вызывает `c._deletePhysicalSelection()` на `draft`-выборе (строки 195–197) и тут же читает результат — `_deletePhysicalSelection` для `kind: 'draft'` делает `this._deleteDraftWhole(); return;` без `await` (`src/houseplan-editor-runtime.ts:3072`), поэтому реальное удаление происходит позже, после клика по несуществующей кнопке — который никогда не случается. - `smoke_registryless_opening.mjs:89-91` тем же способом стабит `window.confirm` и вызывает `card._lockAction(lockId, 'unlock')` без `await`, читая `serviceCalls.at(-1)` сразу же. - `smoke_binding_picker.mjs:33-34`, `smoke_hidden_flag.mjs:83-84,96-97`, `smoke_optional_space_model.mjs:91-93` **ждут** промис (`await c._deleteMarker()` / `await card._deleteSpace()`), но промис `_confirmDanger` резолвится только явным `hp-confirm-decision`, а стаб `window.confirm` больше никем не читается — сцена не диспатчит клик, дизконнект или замену запроса, поэтому `await` виснет бесконечно. Три файла проверял отдельно — во всех трёх процесс не завершается, `finish()` (`demo/serve.mjs:35`, выставляет `process.exitCode = 1` при провале) вообще не достигается. Это хуже явного провала: в CI такой смок не падает быстро, а занимает слот до тайм-аута раннера. Не задеты (проверено, не потребовалось чинить): `smoke_orphan_space_references.mjs` стабит `window.confirm` и ждёт `_deleteSpace()`, но сценарий останавливается на blocker-проверке до `_confirmDanger` (`deleteExplainsBlockerWithoutConfirmOrWrite`), поэтому не виснет — прогнан, зелёный. `smoke_opening_binding.mjs` и `smoke_partition_openings.mjs` вызывают `_lockAction(..., 'lock')` (не 'unlock') и `_deletePhysicalSelection()` на `partition`/`column`-выборе — ни один путь не проходит через `_confirmDanger` по контракту §4.1/§7 ТЗ, поэтому оба зелёные без правок. Это не «сторонний тест устарел сам по себе» — регресс создан ровно теми изменениями, которые в скоупе задачи (§4.1 ТЗ, миграция `_deleteDraftWhole`, `_deleteMarker`, `_deleteSpace`, `_lockAction`). Три из восьми уже адаптированных смоков показывают правильный образец правки — кликать по `hp-confirm .danger-confirm-footer .btn...` после `await c.updateComplete` (см. `demo/smoke_lock_action.mjs`, `demo/smoke_lock_invariant.mjs`, `demo/smoke_saved_plans.mjs`), тот же приём применим к оставшимся 5 файлам. До правки полный pre-release smoke-прогон (§8) зависнет на трёх из них. **Серьёзность: High.** Блокирует; чинится в этой же задаче (§4.1 её собственный скоуп), issue не заводится. ### L1 — шесть старых односложных ключей i18n остались без потребителей `confirm.delete_draft`, `confirm.delete_draft_segment`, `confirm.remove_marker`, `confirm.delete_space`, `confirm.unlock`, `confirm.delete_plan` присутствуют во всех четырёх словарях, но после миграции ни один файл в `src/**`, `test/**`, `demo/**` их не использует — проверено: ``` $ grep -rn "confirm\.delete_draft'" src/ test/ demo/ # и так для всех шести — пусто ``` ТЗ §8 разрешает удаление старых ключей «только если source/test search доказывает отсутствие потребителей» — этот поиск и есть доказательство. AC-11 (парность словарей) не нарушен: возврат чист, находка не о корректности, а об оставленном мёртвом коде. **Серьёзность: Low.** Не блокирует; можно поправить в этой же правке (удалить шесть ключей из всех четырёх словарей) либо оставить с запиской — на усмотрение автора. ## Что проверено и совпало с кодом - **AC-1/AC-2** — source-contract test (`test/danger-confirmation.test.mjs`, тест «all dangerous-action call sites use the shared confirmation contract») считает 0 вызовов `confirm(` и ровно 8 `await this(.host)?._confirmDanger(` в перечисленных трёх файлах, плюс проверяет все 6 диагностических `key`. Тест умеет падать: `nativeCalls.length === 0` и `sharedCalls.length === 8` оба жёстко зафиксированы, любое отклонение красит тест. - **AC-3/AC-4/AC-5/AC-6/AC-7/AC-8/AC-9** — `demo/smoke_danger_confirmation.mjs` прогнан лично, зелёный; закрывает cancel-safety по всем 6 классам операций (включая оба lazy runtime для plan/space), replace-семантику, race (`staleContextCannotMutate`), фокус/трап/320px/локали. - **AC-10** — `demo/smoke_lazy_editor_chunk.mjs` (не тронут диффом, прогнан лично) остаётся зелёным: `onboardingDoesNotLoadEditor: true` — controller и `hp-confirm` действительно в eager root, не тянут editor chunk. - **AC-11** — `npm test` включает `test/i18n.test.mjs`, generic key-set parity (`assert.deepEqual(Object.keys(dictionary).sort(), enKeys)`) прошёл для всех четырёх словарей. - **AC-12** — читением: `_roomDeleteDialog`, `_partitionDeleteDialog`, `confirm.erase_decor`, backup/optimize диалоги не тронуты диффом (нет их в списке изменённых файлов/функций), `_editorSecondaryDialogBlocked` получил добавление `_dangerConfirm` в общий OR-список блокировки secondary-панели — это тот же паттерн, что уже применён к `_tapConfirm`/`_roomDialog` и не меняет их собственную логику. `smoke_editor_tabs`, `smoke_esc_dialogs` (оба трогают `_markerDialog`/`_roomDialog`/`_spaceDialog`) зелёные. - **AC-13** — см. таблицу гейтов выше, число совпадает с заявкой автора. - **Race-safety §7** — читением всех 7 переписанных call sites (`_deleteDraftWhole`, `_deleteDraftSegment`, `_deleteMarker`, `_deleteServerPlan`×2, `_deleteSpace`×2): каждый захватывает identity (`spaceId`/`draftId`/`segment`/`targetId`/`plan.url+modified`) до `await`, затем перепроверяет её после — включая пересчёт `collectSpaceMarkerDependencies` и `deletingLastSpace` заново для `_deleteSpace`, ровно как требует §7 ТЗ. Дополнительно подтверждено исполнением: `staleContextCannotMutate` в `smoke_danger_confirmation.mjs` меняет `_space` между открытием и `resolve` и проверяет, что геометрия не мутировала. - **Unlock revalidation** — `_lockAction` (`src/houseplan-card.ts:12766`) перепроверяет `_openingEntityAvailable`, что `_openingInfo` — тот же proём/тип/`lock` id, и что состояние всё ещё `'locked'`, иначе не вызывает `hass.callService`. `test/render-device-snapshot.test.mjs` теперь проверяет тот же порядок статическим разбором продакшен-исходника. - **Blocker-проверка раньше confirm** (§4.1 таблица сценариев, «пространство») — в обеих реализациях `_deleteSpace` блокер-чек и ветка «последнее пространство» стоят строго до `_confirmDanger`, без изменений порядка относительно dev. - **Bundle sync** — сгенерированное дерево (`dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/**`) идентично тому, что производит `npm run bundle:sync` на этом SHA (`git status` пустой после локальной сборки). - **Трейлеры** — все 7 коммитов имеют `Issue: #32`; `User-Visible: yes` стоит ровно у коммита `c91ac74e`, и оба CHANGELOG изменены в этом же коммите (`git show --stat c91ac74e`). - **Документация** — `docs/ARCHITECTURE.md` (новый абзац One modal contract), `docs/USER-GUIDE.ru.md` (терминология «Отмена», «Esc», диалог называет объект — согласовано с текущим текстом гайда, не изобретено). ## Чего не проверял - 29 из 39 «прямых» и все 81 «слабых» строк `smoke-select` — обоснование в разделе «Как проверялось». - `npm run golden:verify`, `python -m pytest tests_backend`, performance-профили, `npm run invariants` — обоснование там же (не затронуты диффом/AC). - Ручное браузерное тестирование вне headless-смоков (touch-жесты на реальном устройстве, экранный ридер) — вне возможностей цикла; доверился `demo/smoke_danger_confirmation.mjs` и existing `smoke_esc_dialogs`/ `smoke_dialog_footer_width` как прокси для focus trap/320px/screen-reader разметки (`role`/`aria` наследуются от `hp-dialog`, не проверял вручную скринридером). - Полный прогон всех 209 демо-смоков — не требуется на этапе ревью (PROCESS.md §8, это пред-релизный гейт), но именно поэтому H1 не является «пропустил бы CI» — это ревью и должно было его поймать до пред-релизного гейта, что и произошло. ## Вывод AC-1…AC-13 подтверждены по коду и/или исполнением, кроме побочного эффекта миграции на пять не входящих в §4.1 файлов демо-смоков (H1) — тот же класс регресса, что и в описанном риске ТЗ «синхронный caller продолжит работу до решения», только на testing-инфраструктуре, а не в продакшен-пути. Продуктовый код и текстовый контракт корректны и полностью соответствуют ТЗ; блокирует только H1. Вердикт: жёлтый. Возврат автору для правки 5 смоков (образец есть в этом же диффе) и по желанию — снятия L1.