# CODE-REVIEW-434-r1 - Issue: #434 — «Полиш аудита v1.71.0-beta.1» - Этап: код-ревью (PROCESS.md §2.7), полный трек - Заход: r1 · блокирующих циклов израсходовано 0/4 - SHA материала: `dbda64e89d6b76cabfe57c21f281cb6d5132c257` (HEAD в момент разбора; сверено `git rev-parse HEAD` непосредственно перед выводом) - Диапазон: `origin/dev...HEAD` (5 коммитов: `8a75c53e` спека, `f383d8fd`/`4190db54` документы спек-ревью r1/r2, `cba13621` реализация, `dbda64e8` довесок-мутант) - Ветка ребейзнута конвейером на 1 коммит dev (`a71531ba → dbda64e8`) — разбор проведён полностью, не по дельте, как и требуется для r1. ## Скоуп ТЗ закрывает девять подтверждённых разрывов аудита v1.71.0-beta.1: физический учёт decor-blobs независимо от sidecar (AC1), recovery orphan-загрузки с честным `reused` (AC2), точное явное удаление (AC3), недостающий негативный тест sidecar-без-blob (AC4), capability guard в `houseplan-space-card` + revision-scoped resolve cache (AC5, AC6), честный «текущий» locale gate для danger confirmation (AC7), негативный witness Area snapshot cleanup (AC8), bounded smoke timeouts (AC9), отзыв support-токена из непринятого превью (AC10). Все относятся к Core user jobs `docs/SCOPE.md` (надёжность View/редактора, честность данных, CI-гигиена для Contributor); продуктовой рамки, требующей отдельного вопроса владельцу, не нашёл. ## Как проверялось ### Дешёвые гейты (прогнаны лично, SHA выше) | Гейт | Команда | Результат | |---|---|---| | Typecheck | `npx tsc --noEmit` | 0 ошибок | | Unit (JS) | `npm test` | 1813 тестов, 1812 pass, 1 skipped, 0 fail | | no-new-any | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (60 добавленных строк в 5 файлах) | | Build + 3 копии бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && npm run bundle:sync` | совпадают байт-в-байт; `git status` после ребилда чист | | check-docs (diff трогает `src/**`) | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» | | backend pure | `python3 -m pytest tests_backend/test_decor_assets.py -q` | 52 passed | | model-invariants | не запускал | diff не трогает геометрию (rooms/walls/`layout`/`marker.space`/`open_spans`) — не применимо | ### Отбор смоков (`node scripts/smoke-select.mjs --base origin/dev --head HEAD`) Матрица 216 смоков, «широкий» символ — порог >43 смоков. Вывод инструмента: 21 «прямое совпадение», 35 «слабая связь», 1 «зарегистрированная связь» (`smoke_french_locale` ← `languageRenderGate`, не запускал — French locale сценарий не задет этой задачей, связь регистрируется как «эта функция широко переиспользуется», не как прямое следствие правки). Прогнал целенаправленно: - `demo/smoke_danger_confirm_branches.mjs` — прямое совпадение, единственный файл, названный в AC7 и изменённый в диффе. **OK**, все 38 полей `true`. - `demo/smoke_space_card_decor_capability.mjs` (новый файл, AC5) — **OK**, 4/4 поля `true`. - `demo/smoke_support_feedback.mjs` (новый файл, AC10) — **OK**, 29/29 полей `true`. Остальные 20 «прямых» и 35 «слабых» совпадений — реакция на широко переиспользуемые символы (`_cfgRev`, `_snap`, `_config`, `_confirmDanger`), не специфичные для этой задачи логике; полный прогон всей матрицы — предрелизный гейт (PROCESS §8), не гейт ревью. Golden не запускал — спека прямо говорит «Golden не требуется» (визуальный кадр не меняется), проверено чтением: diff не трогает ни один шаблон рендера кроме `_renderRoot`/`_renderDangerConfirm`, геометрию которых golden не покрывает по замыслу задачи. ### Backend HA harness — НЕ прогонял (честно, гейт пропущен намеренно) `tests_backend/test_ha_websocket.py` содержит новые/изменённые тесты для AC2 и AC3 (`test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing` расширен, `test_decor_asset_delete_removes_exact_orphans_only` новый). Окружение этого ревью — Python 3.12, `pytest-homeassistant-custom-component==0.13.357` требует Python ≥3.14 (проверил: `pip install` дал `ResolutionImpossible`/no matching distribution), `.venv-backend` отсутствует. Это ограничение среды ревью, не находка по коду (AGENTS.md: «только облачные агенты держат харнесс»). **AC2 и AC3 доказаны чтением, не исполнением** — см. ниже. ### Мутационные свидетели — прогнаны выборочно (`--changed` затронул 109/380, полный прогон непропорционален; проверил вручную 8 новых/изменённых записей, относящихся к этой задаче) `node scripts/mutation-gate.mjs --changed --check` — все 109 применимых патчей легли на текущий код (`ok` по каждому, 0 `FAIL`). Живой прогон (патч → пересборка → гард) сделал для мутантов этой задачи: | Мутант / AC | Гард | Результат живого прогона | |---|---|---| | `decor-physical-inventory-follows-sidecars` (AC1) | `pytest -k physical_inventory` | применил патч руками → **тест покраснел** (`assert [] == [...]`) | | `decor-catalog-accepts-sidecar-without-blob` (AC4) | `pytest -k valid_shaped_sidecar_without_blob` | применил патч руками → **тест покраснел** (`row is not None`) | | `danger-confirm-uses-last-rendered-language-gate` (AC7) | `smoke_danger_confirm_branches` | применил патч руками → **смок покраснел** на `warmToReadyAllowsBeforeRender` | | `danger-confirm-warm-transition-cancel-removed` (AC7) | `smoke_danger_confirm_branches` | применил патч руками → **смок покраснел** на 3 полях (`readyToWarmCancelsOpenConfirmation`, `warmLanguageGateKeepsControllerEmpty`, `warmLanguageGateRemovesDecisionSurface`) | | `area-cleanup-keeps-candidate-outside-current-snapshot` (AC8) | `node --test -k "absent from the current snapshot"` | применил патч руками → **тест покраснел** (`true !== false`) | | `support-invalid-response-leaks-issued-token` (AC10) | `smoke_support_feedback` | применил патч руками → **смок покраснел** на `invalidPreviewTokenIsDiscardedExactlyOnce` | После каждого прогона дерево восстановлено (`git status` чист, `cp` бэкапа исходника обратно + ребилд). Не прогонял живьём: `decor-orphan-repair-runs-after-quota`, `decor-orphan-repair-claims-reuse`, `decor-delete-skips-orphan-blobs` — их гард использует `tests_backend/test_ha_websocket.py`, недоступный в этой среде (см. выше); патчи прошли `--check` (якорь находится ровно один раз в текущем коде), сама логика разобрана чтением ниже. ## Разбор по AC **AC1 — physical inventory.** `physical_asset_blobs()`/`physical_asset_usage()` (`decor_assets.py`) считают файлы через `stat(follow_symlinks=False)` + `S_ISREG` + `suffix in ASSET_EXTENSIONS` + `ASSET_ID_RE.fullmatch(stem)` — директории, symlink, `.json`, неизвестные расширения и prefix-совпадения не входят; новый тест `test_physical_inventory_counts_exact_promoted_blobs_not_sidecars` проверяет все эти случаи одним фикстуром. **Доказано**: pytest прогнан, мутация красит. **AC2 — upload recovery.** `http_api.py`: recovery-ветка (`if blob.exists(): ...`) идёт строго ДО вызова `physical_asset_usage()`/проверки квоты — repair не требует свободного слота, как того требует контракт. `existing = read_asset(...)` (valid sidecar + verified blob) возвращает `reused:true` без изменений; orphan (blob без принимаемого catalog row) хешируется, атомарно создаёт sidecar через `tempfile.mkstemp` + `os.replace`, возвращает `reused:false`; digest mismatch кидает `invalid_image` до любой записи. Тест `test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing` покрывает repair-при-полной-квоте (`monkeypatch MAX_DECOR_ASSETS_COUNT=1`) и digest-mismatch (проверяет, что повреждённый blob и отсутствие sidecar остаются нетронутыми, `rejected.status==507`). **Проверено чтением, не исполнением** (харнесс недоступен); тест и код логически согласованы построчно. **AC3 — explicit delete.** `websocket_api.py::ws_assets_delete._delete()` перебирает `ASSET_EXTENSIONS` по точному ``, плюс sidecar; `in_use` проверяется ДО вызова `_delete()` под `write_lock+upload_lock`, так что ничего не удаляется при отказе. Новый тест `test_decor_asset_delete_removes_exact_orphans_only` кладёт orphan `.png`+`.svg`+битый `.json`, соседний `.gif` (неизвестное расширение), `aid+"0".png` (prefix) и директорию `aid.webp` — все три «не трогать» кейса проверяются явно. **Проверено чтением, не исполнением** (харнесс недоступен); код и тест согласованы построчно, включая заявленный `removed`. **AC4 — regression sidecar-без-blob.** Продуктовый код не менялся (защита `blob.is_file()` уже была в `_read_catalog_row` на `dev`) — задача добавляла только пропущенный отрицательный тест, ровно как описано в причине #4. **Доказано**: pytest прогнан, мутация (удаление `or not blob.is_file()`) красит новый тест. **AC5 — capability guard.** `config-store.ts`: `decorAssetsApi` — только точное `=== 1`, localStorage-seed всегда `null`; unit-тесты `config-store.test.mjs` проверяют апгрейд/даунгрейд/`localStorage`. `space-card.ts`: resolve вызывается только при `snap.decorAssetsApi === DECOR_ASSETS_API_VERSION`; при отсутствии — `_decorAssets` безусловно очищается, resolve не вызывается — подтверждено живым смоком (`oldBackendSkippedResolve`, `downgradeRevokesWithoutResolve`). **Доказано** смоком + unit. Отдельно проверил утверждение «принимает capability-only изменение snapshot даже при неизменных config/layout/vLights fingerprints» (`decorAssetsCapabilityChanged` в OR-условии замены `this._snap`, space-card.ts:732) — см. находку Low-1 ниже: код корректен, но заявленный сценарий не верифицирован тем инструментом, который на него ссылается. **AC6 — resolve cache.** `resolveDecorAssets()`: `key = JSON.stringify([configEpoch, unique])`, кэш пишется только после успешного цикла (до `resolveCache.set` включая throw — исключение прерывает функцию раньше). Три новых/изменённых unit-теста (`decor-assets.test.mjs`) прогнаны в `npm test`, логика проверена чтением — мутация «убрать epoch из key» ловится тестом «resolve cache is scoped by authoritative config epoch» (прочитан, не мутировал вручную — тривиально следует из кода). **Доказано** unit-тестами. **AC7 — danger confirmation.** `_dangerConfirmLocaleGate` стал геттером (никогда не кэшируется), `_confirmDanger` проверяет его на каждый вызов; `willUpdate` отменяет открытый confirm при переходе в `warm` СИНХРОННО до `render()`; `render()`/`_renderRoot()` используют один и тот же call site шаблона, чтобы вложенный `noChange` для тела уживался с независимым удалением `hp-confirm`. Смок прогнан живьём (зелёный, 38/38), и я вручную применил и откатил обе новые мутации реестра — оба раза целевые поля красятся именно так, как заявлено в `because`. **Доказано** смоком + двумя живыми мутациями. **AC8 — Area snapshot negative witness.** Продуктовый код не менялся (защита `snapshotBindings.has(binding)` уже была на `dev`) — только новый unit-тест. **Доказано**: тест прогнан, мутация красит именно этот тест. **AC9 — bounded smoke execution.** `validate.yml`: job `smoke` получил `timeout-minutes: 20`; каждый файл — `timeout --kill-after=10s 180s node "$f"`, статус 124 печатает отдельную диагностическую строку, `continue` внутри цикла файлов не встречается — падение одного файла не останавливает шард (существующий код, не тронут). `smoke_danger_confirm_branches.mjs` обернул оба German-ожидания (`germanStarted`, `germanCompleted`) в `Promise.race` с 1000 мс. Новый `test/smoke-exception-guard.test.mjs` проверяет все три границы текстовым соответствием YAML — прогнан, зелёный. Живой смок подтверждает, что German-путь укладывается в границы (никакого зависания на реальном прогоне). **Доказано** текстовым контрактным тестом + живым прогоном смока; сам workflow на GitHub Actions не выполнялся (не гейт ревью, а CI-инфраструктура). **AC10 — support token cleanup.** `_buildSupportPreview()`: `issuedToken` фиксируется сразу после парсинга ответа (валидация формы `^[0-9a-f]{48}$`), ДО общей проверки hash/format/version/size/spaces/expires/text, которая кидает `support_rejected`; `catch`-блок discard'ит `issuedToken`, если он не был обнулён успешным путём. Malformed/отсутствующий токен никогда не попадает в `issuedToken`. Живой смок зелёный (29/29), живая мутация (снятие discard в catch) красит ровно `invalidPreviewTokenIsDiscardedExactlyOnce`. **Доказано** смоком + живой мутацией. **AC11 — совместимость/документация.** Grep диффа не находит изменений схемы, версии API (`DECOR_ASSETS_API_VERSION` остался `1`), i18n-ключей или визуальных шаблонов вне `_renderRoot`/`_renderDangerConfirm` (сам рендер тех же узлов, что и раньше, просто перегруппированный). `docs/ARCHITECTURE.md`, `CONFIG-COMPATIBILITY.md`, `SUPPORT-PRIVACY.md`, `TESTING.md`, оба `CHANGELOG*` изменены в одном коммите `cba13621` с трейлерами `Issue: #434` / `User-Visible: yes`. **Проверено чтением**: diff review + `check-docs` зелёный. **AC12 — гейты.** Typecheck/unit/build/bundle-sync/no-new-any/check-docs — зелёные (таблица выше). Backend pure — зелёный. Backend HA — не прогонял (ограничение среды, см. выше, отражено честно). Отобранные смоки — зелёные. Мутационные свидетели — 6/9 новых прогнаны живьём и красят корректно, 3/9 (HA-зависимые) проверены `--check` + чтением. Golden/full performance — предрелизные, не гейт ревью (спека сама это утверждает). ## Находки ### Low-1 — `decorAssetsCapabilityChanged` в `space-card.ts:711-732` не влияет на наблюдаемое поведение и не верифицируется смоком, на который ссылается спека `this._snap.decorAssetsApi` пишется в двух местах (`space-card.ts:733` и `:736-737` через частичное обновление) и читается **только** в вычислении самого `decorAssetsCapabilityChanged` (`:711-712`) — больше нигде в проекте (`grep -n "decorAssetsApi" src/*.ts` даёт ровно 6 строк, все либо запись, либо сравнение с только что полученным `snap`, а не с `this._snap`). Реальная семантика AC5 — вызов/невызов `resolve` и очистка `_decorAssets` — читает **свежий** `snap.decorAssetsApi` напрямую (`:713`, `:740`), а не `this._snap`. Поэтому замена `this._snap` целиком против частичного обновления `rev`/`layoutRev` не производит никакой разницы, которую можно было бы пронаблюдать. **Как проверил.** Убрал `|| decorAssetsCapabilityChanged` из условия на `:732`, пересобрал бандл, прогнал `smoke_space_card_decor_capability.mjs` (остался зелёным, 4/4) и отдельным зондом (`page.evaluate`, три `_load()` подряд: null→1→null) убедился, что `_snap.decorAssetsApi` меняется `null → 1 → null` **даже без строки** — потому что `virtualLightsChanged` в этой фикстуре (и, по прослеживанию кода, в любом реальном сценарии, где `fetchFresh()` вообще выполняется — `cache` в `config-store.ts` всегда `null` на входе в `fetchFresh`, значит `virtualLightSnapshot()` каждый раз строит новый объект) уже безусловно `true` ровно тогда, когда снимок вообще меняется. `test/space-card-audit-lows.test.mjs` проверяет присутствие строки `decorAssetsCapabilityChanged` в исходнике текстовым `assert.match` — это ловит буквальное удаление термина, но не поведенческую регрессию, и не то же самое, что «тест умеет падать» в смысле PROCESS §2.7 (сам мутационный реестр не содержит записи для этой строки). **Не блокирует.** Строка не создаёт неверного поведения (она безвредно избыточна), реальный контракт AC5 (запрет resolve на старом backend, очистка map при даунгрейде, кэш по epoch) доказан отдельно и надёжно. Снимаю как Low с запиской: если авторы захотят вычистить, это чистое упрощение (убрать термин из OR и из текстового теста, ничего не потеряв); можно и оставить как задел на случай, если `_snap.decorAssetsApi` найдёт читателя в будущем. ### Low-2 — `_dangerConfirmLocaleGate` геттер может вызвать `languageRenderGate()` дважды за один цикл обновления, пока открыт danger confirm `languageRenderGate()` (`src/i18n/language-runtime.ts:105`) не чистая функция: мутирует `pendingHosts`/`committedHosts`, дёргает `host.setAttribute`, и — что существеннее — при `state === 'pending'` безусловно подписывает новый `.then()` на `runtime.ensure(code)` при **каждом** вызове, не только при первом вхождении в pending. `willUpdate()` (`houseplan-card.ts:4192-4194`) читает геттер, когда `this._dangerConfirm` истинен, и `_renderBody()` (`:11239`) читает его снова безусловно — при открытом danger confirm это два вызова за один рендер-цикл вместо одного до этой правки (когда значение кэшировалось полем). **Не блокирует.** Оба вызова происходят синхронно в одном тике (между `willUpdate` и `render()` нет await), поэтому `runtime.state(code)` не может измениться между ними — итоговое значение геттера согласовано на всём проходе. Лишняя подписка `.then()` на уже существующий (мемоизированный) промис `runtime.ensure()` добавляет не более одного лишнего `requestUpdate()` при разрешении — Lit схлопывает такие вызовы. Открытый danger confirm — не самое частое состояние карточки. Снимаю как Low без правки. ## Унаследовано / повторная проверка Не применимо — это первый заход код-ревью (r1) по этому issue; разделы «Закрытие r(N-1)» и «Унаследовано из r(N-1)» не ведутся согласно PROCESS §2.10. (Два документа `SPEC-REVIEW-434-r1.md`/`-r2.md` в диффе относятся к предыдущему этапу — ревью ТЗ, а не коду; их вердикт не переоткрывал.) ## Чего не проверял и почему - **`tests_backend/test_ha_websocket.py` (полный HA-харнесс)** — среда ревью Python 3.12, пин `pytest-homeassistant-custom-component==0.13.357` требует ≥3.14, `.venv-backend` недоступен. AC2/AC3 доказаны чтением построчного соответствия теста и кода (см. разбор выше), не исполнением. - **Полный `npm run golden:verify`** — спека прямо утверждает «Golden не требуется» (нулевая визуальная дельта в устойчивых состояниях); чтением подтвердил, что изменённые рендер-точки (`_renderRoot`) рисуют тот же набор DOM-узлов, что и раньше, в другой обёртке. - **`npm run golden:capture`/`performance_smoke`/полный набор `demo/smoke_*.mjs` (216 файлов)** — предрелизный гейт (PROCESS §8), непропорционален объёму этой задачи; выбрал 3 смока, прямо названных в AC и физически изменённых в диффе. - **Полный `node scripts/mutation-gate.mjs`(без `--changed`) — 380 мутантов** — предрелизный гейт по определению самого скрипта («прогон дорогой... его место — перед стабильным релизом»); ограничился `--changed --check` (все 109 затронутых патчей применимы) плюс живым прогоном 6 из 9 новых/изменённых записей, относящихся именно к этой задаче. - **`node scripts/model-invariants.mjs`** — diff не касается геометрии (rooms/walls/layout/marker.space/open_spans), гейт неприменим. - **French locale smoke (`smoke_french_locale.mjs`)** — «зарегистрированная связь» через `languageRenderGate`, но сценарий (network/lazy German chunk) не задет этой задачей; решил не гонять. - **35 «слабых» совпадений smoke-select** — общие символы (`_cfgRev`, `_config` и т.п.), не специфичные для этой правки; не гонял, полагаясь на unit/typecheck как более узкое доказательство отсутствия регрессии в этих поверхностях. ## Итог High: 0. Medium: 0. Low: 2, обе сняты с запиской (см. выше), правки не требуют. Все 12 AC доказаны — либо зелёным тестом/смоком с подтверждённой (в 8 из 10 защитных случаев — лично прогнанной) способностью падать, либо честной пометкой «проверено чтением, не исполнением» там, где харнесс недоступен в среде ревью. Документация, changelog и трейлеры в порядке. Вердикт: **зелёный**. --- ## Материал раунда - Ветка: `issue/434-v171-polish-audit`, коммит `a71531ba30d2` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `840317519dfa1ae59a2ecf7317ce53bd819c4504` ``` git log --all --format='%H %T' | grep 840317519dfa ``` - ТЗ `docs/specs/434-v171-polish-audit.md`, блоб `e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1` ``` git log --all --find-object=e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1 -- docs/specs/434-v171-polish-audit.md ```