Files
houseplan-card/docs/reviews/CODE-REVIEW-500-r1.md
T
2026-09-10 11:41:16 +00:00

249 lines
23 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-500-r1
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
- **Материал:** `0905f67ec0d8fb830065e909988f442c884320c2` (ветка `issue/500-config-adoption-boundary`, ребейз на `origin/dev` = `0c748282`)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 10 коммитов (4 ТЗ/докс-ревью + 1 реализация модуля + делегаты + 7 вызывающих + 1 тесты/lint/docs + 1 фикс реактивности)
- **ТЗ:** тело `docs/specs/500-config-adoption-boundary.md`, зелёное ревью r3 (`docs/reviews/SPEC-REVIEW-500-r3.md`)
- **Заход:** r1 · блокирующих циклов израсходовано 0/4 (это первый заход)
## Скоуп
Извлечение владения идентичностью `{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 не тронут.
## Как проверялось
**Дешёвые гейты** — не гонял заново: Validate на точном материале ревью
(`0905f67e`) зелёный целиком —
https://github.com/Matysh/houseplan-card/actions/runs/34462124328 (`headSha`
подтверждён `gh run view` перед использованием результата). Это покрывает
`tsc --noEmit`, `npm test` (2494+ юнитов), `npm run build` + сверку бандл-
деревьев, `no-new-any`, `process-gate`, `check-docs` (WARN о протухшем
скриншот-отпечатке — это предупреждение обычного push, не гейт кандидата,
блокирующим не является).
**Мутанты по диффу** — проверил логи всех трёх шардов `Мутанты по диффу
(N/3)` этого же прогона (`gh run view --job <id> --log`), а не только факт
green: все четыре новых мутанта пойманы на материале ревью —
| Мутант | Файл лога (шард) | Результат |
|---|---|---|
| `config-adoption-echo-clears-history` | 2/3 | `тест покраснел, как обязан` |
| `config-adoption-rev-from-foreign-response` | 3/3 | `тест покраснел, как обязан` |
| `config-adoption-rollback-ignores-rev` | 1/3 | `тест покраснел, как обязан` |
| `post-write-skips-asset-gate` | 3/3 | `тест покраснел, как обязан` |
Плюс чистые (немутированные) прогоны в тех же логах подтверждают отсутствие
регрессии по соседним защитам на этом материале: `node --test
test/config-adoption.test.mjs`, `node demo/smoke_room_resize.mjs` (сам фикс
реактивности `0905f67e` — эту же регрессию поймал предыдущий красный Validate
на `c360bcc9`), `node demo/smoke_summary_panel.mjs`, `node demo/
smoke_marker_write_rollback.mjs`, `node demo/smoke_device_position_history.mjs`,
`node demo/smoke_summary_panel_polish.mjs` — все чистые.
**Браузерные смоки, не покрытые CI на этом SHA.** Тяжёлые job'ы (`smoke`,
`golden`, `performance_smoke`) в этом прогоне `skipped` — обычный push без
`Release:`, не кандидат (см. `PROCESS.md` §8/AGENTS.md, ожидаемо). Отдельно
проверил: ни `demo/smoke_post_write_adoption.mjs` (новый смок, единственное
прямое свидетельство AC4), ни `demo/smoke_danger_confirmation.mjs` не
зарегистрированы guard'ом ни одного мутанта в `scripts/mutation-gate.mjs` —
значит job «Мутанты по диффу» их тоже не запускал ни разу на этом материале
(грепом по логам всех трёх шардов подтверждено отсутствие). Автор сам об этом
написал в хендоффе («в моей среде нет Chromium»). Прогнал сам:
```
npm run build && npm run bundle:sync
node demo/smoke_post_write_adoption.mjs → OK, все 20 проверок true
node demo/smoke_danger_confirmation.mjs → OK, все 18 проверок true
```
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` даёт 47 прямых
совпадений (порог «широкого» — больше 47, эта задача чуть ниже порога, но
широка по природе диффа: тронуты повсеместно читаемые `_cfgRev`/`_layoutRev`/
`_model`/`_serverCanWrite`). Полный прогон всех 47 непропорционален объёму
находок и является предрелизным гейтом (§8); выбрал те, что прямо касаются
изменённых путей (adoption/rollback/reactivity) и либо подтвердил зелёными
через логи CI-мутантов, либо прогнал сам. Остальные 45 не прогонял — по
диффу они читают эти поля так же, как раньше (делегаты не меняют семантику
чтения), а не изменённое поведение.
**Инварианты модели** — диффом задета идентичность, не геометрия рёбер/
толщины/ссылок; `npm run invariants` не запускал (не требуется правилом:
геометрия и её ссылки не менялись, только владелец ревизии/тела).
**pytest/golden/perf** — не задеты диффом (только `src/**`, backend не
тронут; визуал не меняется, ТЗ §7 и §14 это явно фиксируют); не гонял.
## AC → доказательство (проверено чтением и/или прогоном)
| AC | Чем доказан | Проверено | Чем краснеет |
|---|---|---|---|
| AC1 | `test/config-adoption-ownership.test.mjs` | чтением: пересчитал оба регекса вручную по `src/**` — `_serverCfg =` 5(editor)+3(card), `_layout =` 5(editor)+9(card), совпадает с allowlist дословно; `IDENTITY_WRITE` не находит присваиваний вне модуля | вернуть присваивание в любом модуле — красный (мутация не нужна, тест — прямой grep) |
| AC2 | `test/config-adoption-ownership.test.mjs` + чтение | подтверждено: `_adoptStructuralResponses` как host-метод не существует нигде вне `config-adoption.ts`; `SummaryPanelHost`/`HouseplanEditorHostPort` сужены точно по §6.4 | оставить старый метод в любом файле — красный |
| AC3 | `test/config-adoption.test.mjs` (17 кейсов) + существующие смоки/юниты | таблица переходов (эхо/смена/только layout/virtual lights/gated-последовательность/reactive-контракт) прочитана целиком, поведенчески эквивалентна старому `_adoptStructuralResponses`; `post-write` подтверждён без пост-шагов тестом на строке 215 | мутант `config-adoption-echo-clears-history` пойман на материале (см. таблицу выше) |
| AC4 | `demo/smoke_post_write_adoption.mjs` + I2-юнит | **прогнал сам** — 20/20 true; частично не покрыт: ветка отказа гейта (`asset-wait`) ни одним из четырёх вызывающих не тестируется (см. находку M-1 ниже) | мутант `post-write-skips-asset-gate` пойман на материале |
| AC5 | `test/config-adoption.test.mjs` round-trip | прочитано, ключи `LS_CFG` побайтово совпадают со старым форматом | мутант не заведён (чистый юнит, признание правила §2.7 достаточно) |
| AC6 | `test/config-adoption.test.mjs` (перенесённые кейсы #439/#442) | прочитано, идентично прежней логике `rollbackOptimistic` | мутант `config-adoption-rollback-ignores-rev` пойман на материале |
| AC7 | `test/core-file-budget.test.mjs`, `bundle:budget`, `test/bundle-assets.test.mjs` | `wc -l src/houseplan-card.ts` = 13605 < потолка 13650; `INITIAL_VIEW_GZIP_CEILING` 300 300 с датированной записью в `scripts/bundle-budget.mjs`; общий бюджет 301 066 не тронут | поднять любой из потолков — красный по правилу теста (не прогонял мутацию, чтение констант и тест-файла достаточно) |
| AC8 | ревью кода | `docs/ARCHITECTURE.md` содержит абзац о границе (владелец, три способа смены ревизии, единая последовательность, оба профиля) — соответствует факту кода | — |
## Находки
### Medium-1 (в скоупе задачи). Post-write гейт: отказ (`asset-wait`) не останавливает хвост вызывающего ни в одном из четырёх путей — единственная защита AC4 не проверена для своей же отказной ветки и частично не работает
**Где:** `src/houseplan-editor-runtime.ts:8693` (`_deleteSpace`, editor),
`src/houseplan-editor-runtime.ts:9575` (`_undoPlanOptimization`),
`src/houseplan-editor-runtime.ts:9734` (`_applyBackupImport`),
`src/houseplan-onboarding-runtime.ts:477` (`_deleteSpace`, onboarding).
**Что происходит.** У трёх reload-вызывающих (`_loadFromServer`,
`_reloadConfigOnly`, summary recovery) при `adopted.status !== 'adopted'`
метод полностью прерывается (`return`/`throw`) — хвост не выполняется вовсе,
структура и на глаз, и в идентичности остаётся прежней, а
`_scheduleLoadRetry(true)` (вызванный внутри `adoptAuthoritativeGated`) сам
подтянет данные позже. Ни один из четырёх **post-write** вызывающих так не
делает:
- `_undoPlanOptimization` (`:9575`) вообще не читает `adopted.status` —
`_geometryHistory.clear()`, `_devicePositionHistory.clear()`,
`_canOptimizeUndo = false`, `_undoKind = null`, `_cfgEpoch++`,
`_maybeRebuildDevices()`, `_cacheSnapshot()` и тост
`gs.optimize_undone`/`backup.import_undone` выполняются безусловно.
- `_deleteSpace` (оба входа, `:8693` и onboarding `:477`) и
`_applyBackupImport` (`:9734`) читают `adopted.status` **только** для
одного шага — выбора видимого пространства (`_commitSpace`/
`_adoptInitialSpace`). Закрытие диалога, `_regSignature = ''`,
`_maybeRebuildDevices()`, `_cacheSnapshot()` (у Import) и финальный тост
выполняются безусловно, тем же порядком, что и при успешном adoption.
**Почему это дефект, а не стиль.** До #500 у post-write путей вообще не было
асинхронного гейта — `_adoptStructuralResponses` было синхронным и всегда
адоптировало то, что пришло; ветки «не адоптировано» просто не существовало.
Она появляется **именно этой задачей** (тем самым единственным намеренным
изменением поведения из §6.3 ТЗ) — и именно поэтому её последствия для
вызывающих — предмет этого ревью, а не наследие. Сценарий реалистичен и
прямо описан в самом ТЗ (§6.3): «Undo/Import могут вернуть другой фон» — то
есть конкурентная смена `plan_url` между записью и повторным чтением, из-за
которой `prepareImage` не готов вовремя, — ровно тот случай, который смок
`demo/smoke_post_write_adoption.mjs` воспроизводит для успешного пути
(`assetReady` всегда `true` в фикстуре). При отказе `prepareImage`
(например, временная сетевая ошибка на media-source) сервер уже необратимо
выполнил операцию (space удалено / undo применён / импорт применён), а
карточка: (а) для Undo — показывает тост «отменено» и гасит аффорданс Undo,
хотя видимая модель ещё старая (пост-оптимизационная, не отменённая) —
несовпадение тоста с фактическим состоянием экрана; (б) для всех четырёх —
пересобирает устройства/кэширует снимок по **старой** (неадоптированной)
идентичности, хотя гейт был явно введён, чтобы не показывать переходное/
рассинхронизированное состояние. Ретрай внутри `adoptAuthoritativeGated`
рано или поздно подтянет верные данные, поэтому дефект самоисцеляющийся и не
портит серверные данные — отсюда Medium, а не High.
**Чем красит/красится.** Ни `test/config-adoption.test.mjs`, ни
`demo/smoke_post_write_adoption.mjs` не подают `assetReady: false` ни в один
из четырёх post-write сценариев — ветка `status !== 'adopted'` для
post-write профиля ни разу не проходится ни юнитом, ни смоком. Убедиться
легко: временная правка `card._signer.prepareImage = async () => false;`
перед любым из четырёх вызовов в `demo/smoke_post_write_adoption.mjs`
сегодня не роняет ни одну из существующих проверок смока (они не смотрят на
это состояние) — то есть отсутствие проверки подтверждено, а не
предположено.
**Требуется:** привести хвосты `_deleteSpace` (оба входа),
`_undoPlanOptimization`, `_applyBackupImport` к тому же правилу, что уже
работает в reload-профиле — полностью пропускать хвост (закрытие диалога,
очистки, тост, `_cacheSnapshot`, выбор пространства) при
`adopted.status !== 'adopted'`, либо явно обосновать в ТЗ/ревью, почему
post-write хвост обязан выполняться даже без структурного adoption — и
покрыть выбранное поведение тестом/смоком, который умеет упасть на снятой
защите. Находка в скоупе задачи (это ровно те четыре пути, которые ТЗ §6.3
называет объектом гейта) — чинится в этом же issue, второй ревью-цикл не по
лимиту §4, а по итогу вердикта.
### Low-1 (снимается записью, не требует правки). Риск-таблица ТЗ §12 содержит неверный факт о `static properties`, но код всё равно корректен
ТЗ (§12) утверждает: «`_serverCfg`, `_layout`, `_cfgRev` и fingerprint не
входят в `static properties` хоста (проверено на `a824acc1`)». Проверка
чтением `git show a824acc1:src/houseplan-card.ts` показывает, что `_layout`
и `_serverCfg` **были** объявлены `{ state: true }` уже на этом коммите (до
#500) — то есть до задачи они были обычными реактивными Lit-полями без
собственных аксессоров, и замена их на кастомные getter/setter действительно
меняла контракт реактивности (Lit пропускает генерацию своего аксессора,
когда `prototype.hasOwnProperty(name)` истинно). Утверждение спеки было
неверным в момент зелёного ревью ТЗ r1–r3 — но реальный код это не сломало
благодаря отдельному фикс-коммиту `0905f67e` (`onBodyReplaced` →
`requestUpdate`), написанному именно потому, что red Validate на `c360bcc9`
поймал регрессию `smoke_room_resize` от этого же факта. Итог: код корректен и
подтверждён смоком/юнитом, найденная неточность — в тексте уже принятого ТЗ,
которое не переоткрывается этим ревью. Фиксирую как Low без блокировки.
## Что проверено и корректно
- Модуль `src/config-adoption.ts`: ссылочная идентичность (I4), приём
ревизии только с телом (I2), эхо не трогает историю/эпоху (I3) — все три
инварианта подтверждены и чтением, и тестами/мутантами.
- Реактивный контракт `onBodyReplaced` → `requestUpdate(field, previous)`
корректно воспроизводит поведение снятого Lit-аксессора; `willUpdate`
(`houseplan-card.ts:4111`) по-прежнему держит инвариант эпохи геометрии.
- Сужение `SummaryPanelHost`/`HouseplanEditorHostPort` до одного метода —
соответствует §6.4 дословно (проверено построчно).
- `plan-optimize-write.ts`, `space-copy-runtime.ts`, `vacuum-calibration-write.ts`,
`editors/vacuum-maps-section.ts` — переведены на `acceptPairWrite`/
`beginOptimistic`/`stageLocalConfig`/`host._rollbackOptimistic`, прямых
записей идентичности не осталось (подтверждено AC1-тестом и ручным grep).
- `serialized-write-queue.ts` лишился `rollbackOptimistic`; единственный
вызывающий отката — `houseplan-card.ts` (`_rollbackOptimistic`), как того
требует AC2-тест.
- Бюджеты AC7: факт (13605 строк, 299 771 Б) внутри новых потолков с
датированной записью причины пересчёта.
- Трейлеры всех десяти коммитов: `Issue: #500` + `User-Visible: no`,
changelog не тронут — согласовано с `User-Visible: no`.
- `docs/ARCHITECTURE.md` (AC8) описывает границу фактически точно.
## Чего не проверял и почему
- Полный набор из 47 «прямых совпадений» `smoke-select` — прогнал только те,
что напрямую относятся к изменённому поведению (adoption/rollback/
reactivity), остальные читают делегируемые поля так же, как раньше;
полный набор — предрелизный гейт (§8).
- `npm run invariants` — диффом не задета геометрия/толщина/ссылки, только
владение ревизией и телом.
- `python -m pytest tests_backend` — backend не тронут диффом.
- `golden:verify`/`performance_smoke` — визуал и перф-профили не в AC и не
меняются по диффу (подтверждено ТЗ §7/§14 и составом diff: только `src/**`
frontend, никаких изменений в рендер-путях).
- Собственный повторный прогон `tsc`/`npm test`/`npm run build` — не
дублировал: зелёный Validate подтверждён на точном материале ревью через
`gh run view` (headSha сверен).
## Материал раунда
- SHA материала: `0905f67ec0d8fb830065e909988f442c884320c2`
- Дерево: рабочая копия уже на этом SHA (`git rev-parse HEAD` совпадает)
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-500-r3.md` на коммите `622470ff`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/500-config-adoption-boundary`, коммит `0905f67ec0d8` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5c8ab48044237c58112ba21b6d38a8de22a371c1`
```
git log --all --format='%H %T' | grep 5c8ab4804423
```
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `892240ddf10b28b483d865aa21e7132a1c0b7aaa`
```
git log --all --find-object=892240ddf10b28b483d865aa21e7132a1c0b7aaa -- docs/specs/500-config-adoption-boundary.md
```
- Вердикт конвейера: `yellow` · High 0