Files
houseplan-card/docs/reviews/CODE-REVIEW-434-r1.md
T
Codex d4dd027b0a build: prepare v1.71.0-beta.2 candidate
Issue: #426
Issue: #427
Issue: #428
Issue: #431
Issue: #432
Issue: #434
User-Visible: no
2026-09-03 15:23:40 +03:00

316 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` по точному `<aid><ext>`, плюс 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 и трейлеры в порядке. Вердикт: **зелёный**.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `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
```