Files
houseplan-card/docs/reviews/CODE-REVIEW-491-r1.md
T
2026-09-08 23:01:49 +00:00

305 lines
24 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-491-r1
Issue: [#491](https://github.com/Matysh/houseplan-card/issues/491) — «Optimize/Undo: не терять незавершённую парную транзакцию при следующей записи».
Материал ревью: ветка `issue/491-optimize-undo-pair-recovery`, **точный SHA `4c08b8abfca4a81240f98d654cc4766faae8ec58`** (рабочая копия уже на нём).
Заход: r1. Блокирующих циклов израсходовано: 0/4.
ТЗ: `docs/specs/491-optimize-undo-pair-recovery.md`, ревью ТЗ зелёное (`SPEC-REVIEW-491-r1`, `S5-ready`).
## Скоуп диффа
`git diff origin/dev...HEAD --stat`:
```
custom_components/houseplan/__init__.py | 86 ++----
custom_components/houseplan/store.py | 151 ++++++++++
custom_components/houseplan/websocket_api.py | 251 ++++++++++++-----
docs/ARCHITECTURE.md | 26 +-
docs/CHANGELOG.md | 5 +
docs/CHANGELOG.ru.md | 7 +
docs/CONFIG-COMPATIBILITY.md | 20 ++
docs/TESTING.md | 15 +
docs/USER-GUIDE.md | 7 +
docs/USER-GUIDE.ru.md | 8 +
docs/reviews/SPEC-REVIEW-491-r1.md | 165 +++++++++++
docs/specs/491-optimize-undo-pair-recovery.md | 385 ++++++++++++++++++++++++++
docs/specs/README.md | 1 +
scripts/mutation-gate.mjs | 87 ++++++
tests_backend/test_ha_import_export.py | 31 +++
tests_backend/test_ha_websocket.py | 357 ++++++++++++++++++++++++
16 files changed, 1461 insertions(+), 141 deletions(-)
```
Изменение backend-only (класс A: `custom_components/houseplan/*.py`; класс B:
`scripts/mutation-gate.mjs`, `tests_backend/**`; класс C: `docs/**`). `src/**`
не тронут — фронтенд-гейты (typecheck, smoke, golden, check-docs) вне
обязательного набора для этого диффа.
4 коммита на ветке, все с `Issue: #491`; коммит `ba52a10f` (`User-Visible: yes`)
несёт весь код и правки обоих changelog в одном коммите — трейлеры и правило
«документация в том же коммите» соблюдены.
## Как проверялось
### Дешёвые гейты — прогнаны сам, на `4c08b8ab`
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| JS unit | `npm test` | **2318 passed, 1 skipped, 0 failed** |
| Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | зелёный, бандл побайтово совпадает (ожидаемо: `src/**` не менялся) |
| `no-new-any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (0 строк в 0 файлах — diff не по TS) |
| `smoke-select` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет... браузер-smoke этим диффом не выбираются» — прогон смоков не нужен |
| `process-gate` | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
| Синтаксис Python | `python3 -m py_compile custom_components/houseplan/{store,websocket_api,__init__}.py` | OK |
| Python pure-subset | `python3 -m pytest tests_backend -q` (после `pip install voluptuous==0.15.2`) | 395 passed, 4 skipped — **не проверяет новый код**: `test_ha_*.py` пропущен, `conftest.py` игнорирует эти файлы без `homeassistant` |
Не прогонялись и почему:
- `golden:verify`, `check-docs.mjs`, browser-smoke — не нужны: `src/**` не
менялся, видимый рендер не меняется, `smoke-select` подтвердил пустой выбор.
- `npm run invariants` — диффа геометрии/ссылок (`layout`, `marker.space`,
`open_spans`, записи толщины) нет; меняется только протокол персистентности
уже существующих config/layout документов, не их геометрическое содержимое.
- `python -m pytest tests_backend -q` **с реальным Home Assistant** —
недоступно в этом окружении. `tests_backend/requirements.txt` пинует
`pytest-homeassistant-custom-component==0.13.357`, который требует
Python **>=3.14**; в песочнице стоит Python 3.12.3, и других версий на
машине нет (проверено — `python3.13`/`python3.14` отсутствуют,
`.venv-backend` не создан). Установка `pip install -r
tests_backend/requirements.txt` падает на резолвере с этой причиной.
Все AC1–AC9 этой задачи доказываются исключительно HA-harness тестами —
этот гейт я не смог исполнить и не подтверждаю его результат экспериментально.
### Зелёный CI на этом SHA
По условиям раунда — не найден. Разработчик в хендоффе ссылается на
`exact-SHA CI 98c1923e` и «полный HA backend harness в WSL: 728 passed» — оба
прогона сделаны **до** ребейза на `dev` (конфликт был только в
`docs/specs/README.md`, попытка слияния без ревью отклонена ботом,
issue-комментарий от `2026-09-08T22:06:57Z`). Ни тот прогон CI, ни WSL-прогон
не относятся к точному SHA `4c08b8ab`, который является материалом этого
ревью. Ребейз, по описанию владельца, тронул только строку в
`docs/specs/README.md` (список ТЗ, класс C) — не код; я не могу
пере-проверить это побайтово, потому что предребейзный SHA `98c1923e` уже не
резолвится локально (обычное дело после ребейза, PROCESS §2.10), но
`git diff origin/dev...HEAD` для файлов класса A/B, который я разбирал ниже,
не содержит ничего, что выглядело бы как след мержа/конфликта.
**Вывод:** ни один AC этой задачи не подтверждён исполнением HA-harness ни
мной, ни зелёным CI на этом точном SHA. Все AC1–AC10 ниже доказаны
**чтением, не исполнением** — с указанием, что именно я проследил по коду и
какому сценарию из существующих тестов это соответствует.
## Разбор по коду (проверено чтением)
Прослежены построчно: `custom_components/houseplan/store.py` (новые
`ResolvedStorePair`, `_pending_target`, `async_converge_store_pair`,
`async_resolve_pending_pair`), `custom_components/houseplan/websocket_api.py`
(`_resolved_write_pair`, `PairCommitFailure`, `_persist_pair_intent`,
`_converge_pair`→`_commit_pair`, все точки вызова в `ws_import_apply`,
`ws_layout_set/update/delete`, `ws_geometry_repair`, `ws_config_set`,
`ws_space_delete`, `ws_plan_optimize`, `ws_plan_optimize_undo`) и
`custom_components/houseplan/__init__.py` (перенос recovery до миграции
square-canvas).
Ключевые находки чтения (все — «работает корректно», не дефекты):
- **Разрешение конфликта write fence vs auth.** Во всех восьми writer'ах
`_check_write`/`_runtime` вызываются **до** `_resolved_write_pair` —
предположение ТЗ №5 («проверка permissions остаётся до fence») выполнено
(проверено на `ws_layout_set` L710, `ws_config_set` L1588,
`ws_plan_optimize` L1963 и т.д.).
- **Совместимость со старым форматом pending.** `async_converge_store_pair`
ветвится по `isinstance(pending.get("final_metadata"), dict)`. Ветка
`replace_metadata=False` — дословно перенесённая из старого
`custom_components/houseplan/__init__.py` (был: `if not replace_metadata
and not pending.get("clear_backup") and "optimize_backup" in lay_stored: ...`)
логика сохранения backup по `clear_backup`; существующий
`test_setup_recovers_exact_optimize_storage_roundtrip_pair`
(`tests_backend/test_ha_import_export.py:2282`, не тронут этим диффом)
по-прежнему использует старый формат (`clear_backup: False`, без
`final_metadata`) и по коду обязан пройти тот же путь — AC9 для setup
подтверждён без изменений в самом тесте, только в реализации, которая
теперь общая.
- **Идемпотентность recovery.** Для нового формата (`replace_metadata=True`)
запись config пропускается, если `config_data.get("config") ==
target_config and config_rev == target_config_rev`; запись layout
выполняется всегда (это и есть операция, которая в последний момент снимает
`optimize_pending`) — соответствует §8 ТЗ («target уже записан в обе
половины... повтор удаляет intent без нового смыслового состояния»).
- **Постоянный порядок операций в setup** (`__init__.py`): resolver теперь
вызывается **до** square-canvas миграции (раньше — после), с явным
комментарием о причине; после миграции `optimize_revs` пересчитывается
свежим чтением обоих stores перед `hass.bus.async_fire`, так что событие не
описывает уже устаревшую (домиграционную) ревизию.
- **`_commit_pair`** — прямое переименование бывшего `_commit_import_pair`
(intent → converge → retry once → rollback intent → converge rollback →
`PairCommitFailure`), протокол не менялся, только обобщено имя и убраны
специфичные для импорта строки лога; поведение для Import/`space/delete`
не отличается от `dev` кроме сообщения в логе.
- **Мутационный трейс AC6 вручную** (пояснение ниже, в разделе находок):
прочитан код `_resolved_write_pair`, при отказе `async_resolve_pending_pair`
функция шлёт `commit_failed` и возвращает `None`; вызывающий writer делает
ранний `return`. Гипотетическая мутация «проглотить исключение и продолжить
со свежим (не сведённым) состоянием» превратила бы
`test_issue_491_failed_fence_blocks_point_write_and_keeps_intent` в
проходящий *успешный* ответ вместо `commit_failed` — то есть тест по факту
умеет её ловить. Это прослежено по коду, не исполнено.
Дефектов, ломающих AC1–AC5, AC7–AC10, чтением не найдено.
## Находки
### Medium (в скоупе, чинится в этой же задаче) — 1
**AC6 не имеет названного мутанта, хотя защита живёт в продуктовом коде и
проверяется исключительно дорогим HA-harness гейтом.**
- Файл: `custom_components/houseplan/websocket_api.py`, функция
`_resolved_write_pair` (обработка исключения `async_resolve_pending_pair` →
`commit_failed`, без записи и без удаления pending/backup).
- Доказательство AC6 в хендоффе: `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent`.
Третий столбец таблицы «чем краснеет» в хендоффе — «exact Store pair и
pending сравниваются до/после»: это описание проверок самого теста, а не
название мутации/снятой защиты/отдельной отрицательной пробы с
результатом прогона.
- PROCESS.md §2.7: «Мутант ... обязателен, когда защита живёт в продуктовом
коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не
воспроизведёт отрицательный прогон второй раз... Пустой третий столбец —
находка Medium, а не примечание». AC6 — ровно такая защита (backend,
дорогой гейт, я сам не смог исполнить HA-harness в этом окружении и не могу
подтвердить «падает» без названного мутанта — см. раздел «Чего не
проверял»).
- Сценарий отказа без исправления: если протокол recovery когда-нибудь
сломают правкой рядом (например, вернут `return ResolvedStorePair(...)`
вместо `return None` в except-блоке), ни один существующий мутант в
`scripts/mutation-gate.mjs` этого не поймает — 4 добавленных мутанта
(`pair-recovery-config-writer-skips-fence`,
`pair-recovery-point-writer-skips-fence`,
`optimize-skips-pair-retry-rollback`,
`optimize-undo-skips-pair-retry-rollback`) целятся в отсутствие вызова
fence, а не в то, что происходит, когда fence **сам возвращает ошибку**.
- Что требуется: мутант в `scripts/mutation-gate.mjs`, патчащий
`_resolved_write_pair` так, чтобы отказ `async_resolve_pending_pair`
игнорировался и writer продолжал со свежим (не гарантированно сведённым)
состоянием, с guard на `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent`
(или новый более специфичный тест) и подтверждённым результатом прогона —
либо эквивалентная запись «снял защиту руками, прогнал, вот вывод» в
документации задачи. Без High-находок это влечёт жёлтый вердикт раунда;
находка в скоупе issue #491 (сам мутационный гейт для этой задачи, п.5
«HA-harness fault-injection тесты» из раздела 4 ТЗ) и правится в этой же
ветке, отдельный issue не заводится.
### Low — 0
Не найдено.
## Что проверено и корректно
- **AC1/AC2 (Optimize/Undo — общий retry/rollback).** Прочитаны
`ws_plan_optimize`/`ws_plan_optimize_undo` целиком: обе теперь строят
`pending`/`rollback` с `final_metadata` и идут через `_commit_pair`, тот же
примитив, что Import/`space/delete`. Тесты
`test_issue_491_optimize_failure_restores_before_pair`,
`test_issue_491_optimize_undo_failure_restores_pre_undo_pair`,
`test_issue_491_optimize_fail_after_final_write_is_success` монтируют
реальный `monkeypatch` на `Store.async_save` (не подмену чистой функции) и
сверяют **точное** содержимое обоих store после отказа — структурно
корректный дизайн теста, ловит и «до записи», и «после durable записи»
сбой. Названные мутанты `optimize-skips-pair-retry-rollback`,
`optimize-undo-skips-pair-retry-rollback` целятся именно в замену
`_commit_pair` на голый `_persist_pair_intent + _converge_pair` — точное
попадание в защищаемую строку.
- **AC3/AC4 (ordinary writers резолвят pending до CAS).** Все шесть
ordinary-путей (`config/set`, `layout/set`, `layout/update`,
`layout/delete`, `geometry/repair`) вызывают `_resolved_write_pair` до
чтения revision/CAS; `layout/update` — единственный без CAS — применяет
дельту к `resolved.layout_data` (восстановленному), не к сырому. Мутанты
`pair-recovery-config-writer-skips-fence` и
`pair-recovery-point-writer-skips-fence` целятся именно в удаление вызова
fence на этих двух путях.
- **AC5 (paired writers не стартуют вторую пару поверх первой).**
Общий `_resolved_write_pair` вызывается идентично во всех четырёх paired
writer'ах (`import/apply`, `space/delete`, `plan/optimize`,
`plan/optimize_undo`) — не дублированная логика, а один и тот же вызов.
Для `import/apply` и `space/delete` есть прямые тесты
(`test_issue_491_import_apply_fences_an_older_pending_pair`,
`test_issue_491_space_delete_fences_before_pair_revisions`); для
`plan/optimize`/`plan/optimize_undo` — только чтение кода (тот же вызов на
той же позиции), что соответствует принятому в ТЗ доказательству («тесты и
чтение кода общего входа»).
- **AC7 (setup завершает недоделанное).** `__init__.py` использует тот же
`async_resolve_pending_pair`; порядок операций (resolver → geometry
migration → пересчёт revs → событие) не даёт события со старой ревизией.
Существующий `test_setup_recovers_durable_import_pair` (не изменён, но
проходит по общему коду) и `test_setup_recovers_exact_optimize_storage_roundtrip_pair`
закрывают одновременно новый и legacy формат pending.
- **AC8 (one-deep семантика).** `_discard_optimizer_snapshot` по-прежнему
вызывается в конце `config/set` после успешной обычной правки (инвалидирует
backup); `layout/set`/`layout/update` по-прежнему передают
`remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)`. Логика инвалидации backup
обычным edit'ом не менялась.
- **AC9 (форматы и совместимость).** Persisted-схема не меняется: новых
ключей нет, `final_metadata`/`kind`/`clear_backup` — уже существовавшие
поля, используемые как общий протокол. Legacy pending без `final_metadata`
обрабатывается веткой совместимости (см. выше).
- **AC10 (документация).** `docs/ARCHITECTURE.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`,
`docs/USER-GUIDE(.ru).md` описывают ровно реализованный протокол (write
fence до CAS/no-op, retry→rollback, порядок setup, отсутствие новых
persisted-полей) — сверено построчно с кодом, расхождений нет.
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` в одном коммите с кодом
(`ba52a10f`, `User-Visible: yes`).
- **Трейлеры и процесс.** 4 коммита, каждый с `Issue: #491`; ветка
`issue/491-optimize-undo-pair-recovery`; `process-gate.mjs` зелёный
локально.
## Чего не проверял
- **Исполнение HA-harness backend тестов** (`test_ha_*.py`,
включая все 8 новых `test_issue_491_*`) — недоступно в этом окружении:
`pytest-homeassistant-custom-component==0.13.357` требует Python ≥3.14,
доступен только 3.12.3, других интерпретаторов на машине нет.
Соответственно не проверены исполнением и 4 новых мутанта в
`scripts/mutation-gate.mjs` (`node scripts/backend-test-guard.mjs ...`
тоже требует HA). Все выводы по AC1–AC9 в разделе выше — чтение кода и
сопоставление с существующими (не тронутыми) тестами, не запуск.
- **Зелёный CI на точном SHA `4c08b8ab`** — не существует на момент
ревью (условие раунда). Прогон, на который ссылается хендофф разработчика,
сделан на предребейзном SHA `98c1923e`, недоступном для сверки.
- **Golden/визуальные гейты, браузерные smoke, `check-docs`,
`model-invariants`** — сознательно не прогонялись, обоснование в разделе
«Как проверялось» (нет `src/**`, нет геометрии, `smoke-select` вернул пустой
список).
- **Ручное тестирование в HA** — вне цикла по PROCESS §2; не проводилось
никем.
## Вердикт
Один Medium в скоупе задачи (отсутствие мутационного свидетеля для AC6),
High нет. По PROCESS §2.7/§4 это жёлтый вердикт: цикл возврата автору, без
отдельного issue — правка (мутант или эквивалентная запись прогона со снятой
защитой) делается в той же ветке `issue/491-optimize-undo-pair-recovery`.
Основная реализация (общий crash-resumable commit-протокол, write fence,
setup recovery, обратная совместимость) при чтении кода выглядит корректной
и хорошо согласованной с ТЗ; дешёвые гейты зелёные. Возврат вызван узким
процессным требованием к доказательству одного защитного AC, а не сомнением
в самом протоколе.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/491-optimize-undo-pair-recovery`, коммит `4c08b8abfca4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `697d7e922c8b91078551d280f65add13c92cb79f`
```
git log --all --format='%H %T' | grep 697d7e922c8b
```
- ТЗ `docs/specs/491-optimize-undo-pair-recovery.md`, блоб `c73fde225d4f284d917879b5c4a7a54d1ff7994e`
```
git log --all --find-object=c73fde225d4f284d917879b5c4a7a54d1ff7994e -- docs/specs/491-optimize-undo-pair-recovery.md
```
- Вердикт конвейера: `yellow` · High 0