Files
houseplan-card/docs/reviews/CODE-REVIEW-500-r2.md
T
2026-09-10 12:08:21 +00:00

28 KiB
Raw Blame History

CODE-REVIEW-500-r2

  • Issue: #500 — Архитектура: выделить одну границу владения config/adoption
  • Материал: 93c6c7551b3a7f3df1901e69888611b1a7375ac4 (ветка issue/500-config-adoption-boundary); рабочая копия была на этом SHA на момент старта ревью
  • Заход: r2 · блокирующих циклов израсходовано 1/4 до этого раунда

Почему разбор полный, а не по дельте

Конвейер привёл ветку к dev перед ревью: поверх материала r1 (0905f67e / его копия после переноса истории — da9a58bf) легло три коммита dev (f6eac1d9, 67c780a7, 2783ceff — перестройка джоба «Мутанты по диффу» на 6 шардов с предварительным планированием, #518). После ребейза это другой код (PROCESS.md §7.2), поэтому разбор в этом раунде — полный, не ограниченный дельтой r1→r2.

Фактическая проверка: git diff da9a58bf 93c6c755 (вне dist/** и бандл-деревьев) показывает, что содержимое самой задачи №500 (src/**, test/**, docs/specs/**) не изменилось — ребейз принёс исключительно инфраструктурные правки .github/workflows/validate.yml, scripts/mutation-gate.mjs, PROCESS.md, test/mutation-gate.test.mjs, test/validate-*.test.mjs (#518, не связано с #500). Правки самой задачи r1→r2 — единственный коммит 93c6c755 (тот же, что da9a58bf, с иным родителем), закрывающий Medium-1 r1. Несмотря на это, ниже разобран весь диапазон origin/dev...HEAD (34 нестандартных файла, ~2864/-396 строк), а не только коммит M1-фикса — так требует правило полного разбора при ребейзе, и по факту в ходе разбора это окупилось (см. находку M1-2 ниже, обнаруженную независимым прогоном, а не чтением диффа).

Скоуп

Извлечение владения идентичностью {config, rev, fingerprint} × {layout, rev, fingerprint} в src/config-adoption.ts; единая гейт-последовательность adoptAuthoritativeGated (compare → prepareImage → continuity → adopt → хвост профиля) для семи вызывающих в двух профилях (reload/post-write); сужение SummaryPanelHost и host-контрактов editor-/onboarding-runtime; перенос rollbackOptimistic в модуль; единственное намеренное изменение поведения — гейт готовности фона на четырёх post-write путях, ревизии space/delete берутся из config/get/layout/get, а не из ответа delete. User-Visible: no на всех двенадцати коммитах диапазона, changelog не тронут (подтверждено диффом — ни один файл docs/CHANGELOG*.md не изменён).

M1-фикс этого раунда (93c6c755): все четыре post-write вызывающих (_deleteSpace × 2 рантайма, _undoPlanOptimization, _applyBackupImport) теперь при adopted.status !== 'adopted' полностью пропускают хвост (тост, выбор пространства, очистки, _cacheSnapshot), освобождают busy своего диалога и выходят — как это уже делали три reload-пути.

Как проверялось

Дешёвые гейты — не гонял заново отдельно, но перепроверил напрямую. Validate на точном материале (93c6c755) зелёный целиком: https://github.com/Matysh/houseplan-card/actions/runs/34472616328 (headSha подтверждён). Из осторожности (полный разбор) всё равно прогнал сам и получил тот же результат: npx tsc -p tsconfig.test.json + npm run build (tsc --noEmit + rollup) — чисто; npm test — 2508 pass / 0 fail / 1 skip; npm run bundle:sync — дерево dist/custom_components/houseplan/frontend/demo/srv/assets совпадает (рабочая копия чистая после сборки, git status --short пуст); npm run bundle:budget — initial View 299 774 Б (потолок 300 300 ±2000, запас budget 1292 Б, известное предупреждение низкого запаса #367/#474, не блокирует); node scripts/no-new-any.mjs — 0 новых any на 596 добавленных строках src/**. node --test test/core-file-budget.test.mjs — 7/7, houseplan-card.ts 13605 строк < потолка 13650.

ВАЖНО, разошлось с записью в хендоффе (Matysh, комментарий #12): «Мутанты по диффу» и «Смоки в браузере» в прогоне 34472616328 имеют статус skipped, не success — это обычный push без Release:, тяжёлые джобы на нём не запускаются (см. PROCESS.md §8, ожидаемо). Формулировка «Validate зелёный целиком» в контексте этой задачи верна буквально (все незапущенные джобы = skipped, это не красный), но не покрывает ни один из четырёх мутантов-защитников (config-adoption-echo-clears-history, config-adoption-rev-from-foreign-response, config-adoption-rollback-ignores-rev, post-write-skips-asset-gate), ни браузерные смоки. Прогнал их лично:

node scripts/mutation-gate.mjs --id=config-adoption-echo-clears-history        → поймано 1 из 1
node scripts/mutation-gate.mjs --id=config-adoption-rev-from-foreign-response  → поймано 1 из 1
node scripts/mutation-gate.mjs --id=config-adoption-rollback-ignores-rev       → поймано 1 из 1
node scripts/mutation-gate.mjs --id=post-write-skips-asset-gate                → поймано 1 из 1

Гард всех четырёх мутантов — node --test test/config-adoption.test.mjs (юнит), не браузерный смок; это подтверждает наблюдение r1-ревью, что demo/smoke_post_write_adoption.mjs не зарегистрирован гардом ни одного мутанта и, значит, ни разу не проверялся автоматически на материале ревью ни в r1, ни здесь.

Браузерные смоки — прогнал сам (Chromium доступен в этой среде). Собрал бандл (npm run build && npm run bundle:sync, дерево чистое) и прогнал по прямым совпадениям diff'а (node scripts/smoke-select.mjs --base origin/dev --head HEAD → 48 прямых совпадений, порог «широкого» символа — 47) те, что относятся к изменённому поведению (adoption/rollback/reactivity/M1), а не ко всем 48 — полный прогон остаётся предрелизным гейтом (PROCESS.md §8):

Смок Результат
demo/smoke_post_write_adoption.mjs (AC4, единственный прямой свидетель, переписан этим раундом) КРАСНЫЙ — 1 проверка из 36 падает, см. находку ниже
demo/smoke_room_resize.mjs (свидетель реактивности onBodyReplaced, красневший на предыдущем кандидате r1) OK
demo/smoke_summary_panel.mjs (lost-ACK/summary recovery через _adoption) OK
demo/smoke_danger_confirmation.mjs (глушит _adoptAuthoritative) OK
demo/smoke_device_position_history.mjs (тронут диффом, автор не прогонял — «нет Chromium») OK
demo/smoke_area_relocation_safety.mjs (тронут диффом, автор не прогонял) OK

Остальные 42 прямых совпадения не прогонял — по диффу они читают делегируемые поля (_cfgRev/_layoutRev/_model/_serverCanWrite и т.п.) так же, как раньше (делегаты не меняют семантику чтения), не изменённое поведение; это тот же критерий отбора, что применил r1.

Инварианты модели — не запускал: диффом задета идентичность (revision/fingerprint/owner), не геометрия рёбер/толщины/ссылок; ни layout (структура устройств), ни rooms/wall_segments/marker.space не меняют формат или содержимое, меняется только то, кто и когда пишет их идентичность.

pytest/golden/perf — не задеты диффом (backend не тронут ни одним файлом; визуал не меняется — ни один файл рендера/стилей в диффе); не гонял, как и в r1.

Один источник числа — не применимо: User-Visible: no, пользователю ничего не показывается; единственные числа в диффе — внутренние бюджеты сборки (см. AC7), они не дублируются в интерфейсе.

Находка

Medium-1 (в скоупе задачи, новая в r2). Единственный автоматический свидетель AC4/M1 красный на материале ревью — demo/smoke_post_write_adoption.mjs падает на onboardingDeleteRefusedAdoptsNothing, и ни один гейт CI это не поймал

Воспроизведение (детерминированно, 3/3 прогонов подряд):

npm run build && npm run bundle:sync
node demo/smoke_post_write_adoption.mjs
# ...
# FAILED (1):
#   - onboardingDeleteRefusedAdoptsNothing: expected true, got false

Что происходит. После M1-фикса смок расширен сценариями с assetReady=false (проверка отказной ветки, ровно то, что требовала r1 находка). Для сценария «удаление пространства через onboarding-рантайм при отказе гейта» проверка card._cfgRev === 10 && card._layoutRev === 20 && card._serverCfg.spaces.length === 2 && ... не проходит: card._cfgRev равен 11, хотя adoption корректно отказал (в order нет 'adopt', retries === 1, тостов нет, диалог освобождён — то есть сам M1-фикс отработал правильно).

Причина (доказана чтением и инструментированным прогоном, не догадкой). reset() в смоке (строки 82–93) вызывает adoption.restoreCached(...), но не отменяет отложенные записи — _saveConfigDebounced/_persistLayout (существовавший до #500 механизм; другие смоки, например demo/smoke_danger_confirmation.mjs:196, явно вызывают .cancel() для этой же гигиены). Если к моменту очередного сценария есть не связанная с ним отложенная запись конфига (в этом прогоне она возникает во время предыдущего успешного editorDelete/аналогичного сценария), _deleteSpace()/_applyBackupImport() в начале своего try-блока безусловно вызывают this.host._saveConfigDebounced.flush() (код, не относящийся к #500, не изменён этой задачей) — что немедленно шлёт houseplan/config/set через фейковый card.hass.callWS. Смок не обрабатывает этот тип сообщения (default: return {}), ответ приходит без rev, и MutableConfigAdoption.acceptConfigWrite (одна из трёх санкционированных точек записи ревизии, I1) применяет документированный запасной вариант «rev ?? rev + 1» (§15 п.3 ТЗ) — к чужому, неотносящемуся к текущему сценарию телу. Ревизия увеличивается на 1 (10 → 11) именно в тот момент, когда onboardingDelete-refused уже сбросил состояние через restoreCached. Поскольку эта конкретная итерация — refused (гейт отказывает), собственный adoption сценария так и не перезаписывает испорченную ревизию — она доживает до финальной проверки.

Подтверждено инструментированным прогоном (обёртка acceptConfigWrite/restoreCached во временной копии смока вне репозитория, рабочая копия репозитория не менялась — git status --short пуст на всём протяжении проверки): без правки — cfgRev: 11 и трасса показывает лишний acceptConfigWrite({...}, {}) между двумя restoreCached; с добавлением card._saveConfigDebounced.cancel(); card._persistLayout.cancel(); в reset() — трасса чистая (0 лишних вызовов), все четыре refused-сценария дают cfgRev: 10 корректно.

Это дефект теста, не продукта. С реальным backend houseplan/config/set всегда возвращает rev (см. custom_components/houseplan/ websocket_api.py:1587-1637, ответ без expected_rev/rev — это conflict, не успех); запасной вариант +1 уже задокументирован в ТЗ §15 п.3 как «с реальным backend недостижимо» и не входит в скоуп этой задачи. Сам M1-фикс (ранний return/пропуск хвоста во всех четырёх вызывающих) при этом подтверждён верным — как прямым чтением диффа (см. таблицу AC ниже), так и тем, что ТРИ из четырёх refused-сценариев смока (editorDelete, optimizeUndo, importApply) проходят корректно на этом же материале и том же прогоне.

Почему Medium, а не Low. Это не стилистическая придирка: единственный автоматический свидетель, который должен доказывать закрытие r1-находки M1 для конкретно этого пути (_deleteSpace в onboarding-рантайме), красный на материале, поданном на ревью, и ни один CI-гейт этого не заметил — «Мутанты по диффу» и «Смоки в браузере» на этом SHA skipped (обычный push), а гард всех четырёх мутантов — юнит-тест, не этот смок. Без ручной проверки (эта проверка — «работает ли оно» и есть мандат ревьюера) дефект теста ушёл бы в dev непойманным, а следующий раунд, который тронет этот файл, унаследовал бы красный тест молча.

Требуется: добавить card._saveConfigDebounced.cancel(); card._persistLayout.cancel(); (или эквивалентную изоляцию) в reset() смока demo/smoke_post_write_adoption.mjs, перепрогнать все 36 проверок до зелёного, приложить результат. Правка не продуктового кода — только demo/**, но всё равно проходит ещё один цикл ревью (правило §4: Medium в скоупе чинится и перепроверяется).

AC → повторно проверено на материале r2

AC Статус Как проверено в этом раунде
AC1 (идентичность пишет только модуль) Подтверждено node --test test/config-adoption-ownership.test.mjs (5/5), плюс независимый grep _cfgRev\s*=/_layoutRev\s*= вне config-adoption.ts — ноль совпадений
AC2 (семь путей через adoptAuthoritativeGated, старый seam не встречается) Подтверждено grep -rl _adoptStructuralResponses src/**/*.ts → только config-adoption.ts (внутренняя функция без подчёркивания); typecheck зелёный
AC3 (поведенческая нейтральность, post-write без пост-шагов) Подтверждено node --test test/config-adoption.test.mjs (25/25); smoke_summary_panel, smoke_danger_confirmation, smoke_room_resize — зелёные, прогнаны лично
AC4 (post-write гейт + ревизии из re-read, включая отказную ветку) Частично — код верен (прочитан построчно, все 4 вызывающих симметрично пропускают хвост), но единственный автоматический свидетель красный на материале (Medium-1) demo/smoke_post_write_adoption.mjs — 35/36; unit post-write profile: a refused gate adopts nothing… (r1 M1) зелёный
AC5 (тёплый старт round-trip) Подтверждено (унаследовано, не задето диффом раунда) test/config-adoption.test.mjs round-trip кейсы в общем прогоне 25/25
AC6 (optimistic rollback) Подтверждено (унаследовано) мутант config-adoption-rollback-ignores-rev — поймано 1/1 лично
AC7 (бюджеты) Подтверждено, факт обновлён этим раундом npm run bundle:budget → 299 774 Б, совпадает с записью в scripts/bundle-budget.mjs; core-file-budget 13605 < 13650
AC8 (документация) Подтверждено docs/ARCHITECTURE.md абзац о границе присутствует и фактически точен (сверен построчно)

Что проверено и корректно

  • Сам M1-фикс: все четыре post-write вызывающих (_deleteSpace в houseplan-editor-runtime.ts:8691-8697 и houseplan-onboarding-runtime.ts:477-489, _undoPlanOptimization:9575-9576, _applyBackupImport:9735-9736) симметрично проверяют adopted.status !== 'adopted' и делают ранний return до любого хвоста (тост/выбор пространства/очистки/кэш); для _undoPlanOptimization finally по-прежнему снимает _optimizeUndoBusy — подтверждено чтением управления потоком, return внутри try гарантированно проходит finally.
  • adoptAuthoritativeGated (src/config-adoption.ts:415-446) — единая точка, гейт prepareImage вызывается только при structuralChanged, на отказе не адоптирует ничего и планирует _scheduleLoadRetry(true); корректно для обоих профилей.
  • Три из четырёх refused-сценариев смока (editorDelete, optimizeUndo, importApply) реально доказывают отказную ветку своих путей — не только код прочитан, но и тест прошёл на живом прогоне.
  • Ревизии из delete-ответа больше нигде не читаются (grep по config_rev/layout_rev в теле space/delete подтверждает — оба входа используют только config/get/layout/get).
  • Бюджеты и trailers всех 12 коммитов диапазона (Issue: #500, User-Visible: no) — в порядке; docs/CHANGELOG*.md не тронуты, что согласовано с User-Visible: no.
  • Три коммита dev, принесённые ребейзом (#518, мутанты по диффу на 6 шардов с предварительным планированием), не пересекаются по файлам с продуктовым кодом #500 (кроме общего scripts/mutation-gate.mjs, куда #500 лишь добавляет свои 4 определения мутантов в конец массива — конфликта семантики нет, все 4 подтверждены пойманными лично).

Чего не проверял и почему

  • Полный набор 48 прямых совпадений smoke-select — прогнал 6, относящихся к изменённому поведению; остальные 42 диффом не меняют семантику чтения (та же логика отбора, что в r1). Полный набор — предрелизный гейт (PROCESS.md §8).
  • npm run invariants — диффом не задета геометрия рёбер/толщины/ссылок, только владелец ревизии и тела.
  • python -m pytest tests_backend — backend не тронут диффом.
  • golden:verify/performance_smoke — визуал и перф-профили не в AC и не меняются по диффу.
  • git diff da9a58bf 93c6c755 за пределами src/**/test/**/docs/specs/** (т.е. сами принесённые ребейзом инфра-файлы validate.yml, mutation-gate.mjs §518-часть, PROCESS.md) — не проверял по существу: это код dev, не материал задачи #500, он уже прошёл свой собственный процесс в dev.

Закрытие раунда r1

Находка r1 Чем закрыта Где это видно
Medium-1 (все четыре post-write вызывающих не пропускают хвост при asset-wait, отказная ветка не тестируется) Коммит 93c6c755 добавляет ранний return/скип хвоста во всех четырёх вызывающих + 1 юнит + 16 новых проверок смока на отказную ветку src/houseplan-editor-runtime.ts:8694,9575,9736, src/houseplan-onboarding-runtime.ts:480; test/config-adoption.test.mjs тест «post-write profile: a refused gate adopts nothing…»; смок расширен, но красный на 1/36 — см. Medium-1 этого раунда (продуктовый код верен, тест — нет)
Low-1 (риск-таблица ТЗ §12 неточна про static properties) Строка риска переписана на фактический механизм (onBodyReplaced → requestUpdate) docs/specs/500-config-adoption-boundary.md, диф в 93c6c755

Унаследовано из r1 (только то, что дельта r1→r2 не задевает содержательно)

Несмотря на полный разбор кода в этом раунде (обязателен из-за ребейза, см. выше), ниже — фактические выводы r1, которые я не пересчитывал заново с нуля, а верифицировал спот-чеками, совпавшими с r1 буквально (то есть не «доверие на слово», а подтверждение):

  • AC1/AC2 точные числа (18 присваиваний _serverCfg = в 8 модулях до переноса, allowlist после переноса) — r1 пересчитал вручную по src/**; в этом раунде перепроверено через test/config-adoption-ownership.test.mjs (не переписан диффом M1-фикса) и независимый grep, оба совпадают с выводом r1. Документ r1: docs/reviews/CODE-REVIEW-500-r1.md, материал 0905f67ec0d8.
  • AC5 (тёплый старт round-trip) и AC6 (rollback) логика — код config-adoption.ts в этих методах не тронут коммитом 93c6c755 (diff только в adoptAuthoritativeGated/асимметрии post-write-вызывающих и bundle-budget-комментарии); r1 проверил их чтением и мутантами, здесь перепроверено повторным прогоном тех же мутантов на новом SHA — совпадает.
  • Сужение SummaryPanelHost/HouseplanEditorHostPort (§6.4 ТЗ) — не задето диффом M1-фикса; r1 сверил построчно, здесь не пересматривалось заново, только подтверждено отсутствием изменений в git diff da9a58bf 93c6c755 -- src/summary-panel-host.ts.

Материал раунда

  • SHA материала: 93c6c7551b3a7f3df1901e69888611b1a7375ac4
  • Дерево: рабочая копия была на этом SHA на момент начала ревью (git rev-parse HEAD совпадал); после сборки/тестов дерево осталось чистым (git status --short пуст)
  • ТЗ: docs/specs/500-config-adoption-boundary.md, откорректирован в этом же диапазоне коммитом 93c6c755 (риск-таблица §12, допущение §15 п.8); зелёное спек-ревью docs/reviews/SPEC-REVIEW-500-r3.md
  • Предыдущий код-ревью: docs/reviews/CODE-REVIEW-500-r1.md, материал 0905f67ec0d8, вердикт yellow · High 0 · Medium 1

Материал раунда

  • Ветка: issue/500-config-adoption-boundary, коммит 93c6c7551b3a — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 4967ea22e3c4465c17aab7e4ed7bc929f13acc38
    git log --all --format='%H %T' | grep 4967ea22e3c4
    
  • Тело issue: d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5
  • ТЗ docs/specs/500-config-adoption-boundary.md, блоб b7b4f02c6ca5a57092a360f1f664656b62ed968c
    git log --all --find-object=b7b4f02c6ca5a57092a360f1f664656b62ed968c -- docs/specs/500-config-adoption-boundary.md
    
  • Вердикт конвейера: yellow · High 0