28 KiB
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до любого хвоста (тост/выбор пространства/очистки/кэш); для_undoPlanOptimizationfinallyпо-прежнему снимает_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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
4967ea22e3c4465c17aab7e4ed7bc929f13acc38git log --all --format='%H %T' | grep 4967ea22e3c4 - Тело issue:
d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5 - ТЗ
docs/specs/500-config-adoption-boundary.md, блобb7b4f02c6ca5a57092a360f1f664656b62ed968cgit log --all --find-object=b7b4f02c6ca5a57092a360f1f664656b62ed968c -- docs/specs/500-config-adoption-boundary.md - Вердикт конвейера:
yellow· High 0