Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
28 KiB
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 и трейлеры в порядке. Вердикт: зелёный.
Материал раунда
- Ветка:
issue/434-v171-polish-audit, коммитa71531ba30d2— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
840317519dfa1ae59a2ecf7317ce53bd819c4504git log --all --format='%H %T' | grep 840317519dfa - ТЗ
docs/specs/434-v171-polish-audit.md, блобe235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1git log --all --find-object=e235fcef2bcd817c39c0c5a9134fd2ec8f6eb2f1 -- docs/specs/434-v171-polish-audit.md