Files
houseplan-card/docs/reviews/CODE-REVIEW-340-r1.md
T
2026-08-28 11:34:13 +00:00

117 lines
14 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-340-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/340
- Этап: код-ревью (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- SHA материала ревью: `3e437ca3ec760cad21ca3e15048487bca6ea16b7` (`git rev-parse HEAD` перед подведением итогов)
- Диапазон: `git diff origin/dev...HEAD` (base `356b187e`, 2 продуктовых/гейт-коммита: `c9981e68`, `3e437ca3`; `9239f9d3` — коммит публикации SPEC-REVIEW-340-r1, не код)
- Ветка приведена к `dev` конвейером до ревью (1 коммит dev лёг поверх, `5f4d944f → 3e437ca3`). Разбор ниже — полный, не по дельте: это первый код-ревью цикл этого issue, и после ребейза материал — другой код (§7.2).
## Скоуп задачи
Аудит C4: `ws_config_set` принимал запись без `expected_rev` поверх непустого
store, ограничиваясь warning-ом — тихая потеря чужой работы при двух клиентах.
J6 (`docs/SCOPE.md`) прямо включает multi-client live sync и optimistic
locking, спецификация уже прошла зелёное ревью (`docs/reviews/SPEC-REVIEW-340-r1.md`,
`eb8a5941`). ТЗ фиксирует полный трек (публичный WS-контракт, риск >3) и 7 AC.
Код-ревью проверяет: доказан ли каждый AC, не сломан ли позитивный путь и
задокументирован ли новый контракт там, где раньше был указан warning-only.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | OK, без ошибок |
| Frontend unit | `npm test` | 1462 pass, 1 skip, 0 fail |
| Build + bundle sync | `npm run build` | OK; `dist/**` в диффе отсутствует — src/**\*.ts вообще не тронут этим issue, сверка трёх копий неприменима |
| Docs fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` — запущен по правилу "трогает src/\*\*"? Нет (src не тронут), прогнан всё равно, т.к. `USER-GUIDE.md`/`.ru.md` в диффе — дёшево и подтверждает целостность якорей |
| Backend HA harness | `python3 -m pytest tests_backend -q` (пакеты `pytest-homeassistant-custom-component` и др. установлены вручную в среде ревью — их не было) | `437 passed, 1 skipped, 1 error` |
| Backend targeted | `python3 -m pytest tests_backend/test_ha_websocket.py -k "config" -v` | все 13 отобранных PASSED (включая `test_config_rev_conflict`, `test_issue_340_config_set_without_revision_is_bootstrap_only`) |
| Frontend inventory guard | `node --test test/coordinate-write-barrier-guard.test.mjs` | 2/2 pass |
| Smoke-select | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет (src/\*\*/\*.ts не тронут). Browser-smoke этим диффом не выбираются — выбирать нечего.» → 0 браузерных смоков запущено, обоснованно |
| single-source-numbers | `node --test test/single-source-numbers.test.mjs` | 3/3 pass (диффом не затронуто, прогнан для очистки совести — дёшево) |
| Тест умеет падать | вручную: применил новый `tests_backend/test_ha_websocket.py` к `origin/dev` (код без фикса) | `test_issue_340_config_set_without_revision_is_bootstrap_only` → `FAILED — assert not True` (второй клиент без rev получал success). На `HEAD` тот же тест зелёный → тест реально доказывает контракт, не тавтология |
**1 ошибка (`error`) в полном backend-прогоне — не регрессия.** Teardown-ассерт
HA test harness про «протёкший» поток `_run_safe_shutdown_loop`
(`AssertionError: assert (False or False)`, `threading._DummyThread`).
Воспроизвёл тот же error на чистом `origin/dev` (`test_config_set_purges_tombstoned_and_absent_trails_durably`,
без единой строки из этого issue) — окружение/harness-флейк, не связан с
диффом. Сам тест перед teardown — `PASSED`.
### Не проверялось, и почему
- **`npm run golden:verify`** — не нужен: `src/**` не тронут ни строкой, визуал
не меняется (спецификация §15 и сам diff это подтверждают).
- **Инварианты модели (`npm run invariants`)** — не нужны: диффом не задета
геометрия, `layout`, `marker.space`, `open_spans` или толщина стен; изменение
— исключительно ревизионный guard в `config/set` и его документация.
- **Полный набор `demo/smoke_*.mjs`** — не нужен и не выбран инструментом
(см. таблицу выше): фронтенд не менялся вообще.
- **Performance-профили** — не названы в AC, путь не производительно
чувствительный (одна дешёвая integer-проверка на уже открытом `write_lock`).
## AC — построчно
| AC | Что требуется | Проверено | Как |
|---|---|---|---|
| AC1 | Первая запись без rev на пустом store — ok; любая следующая без rev — `conflict` | ✅ | код `websocket_api.py:1293-1318` (guard внутри `write_lock`, до валидации); backend-тест `test_issue_340_config_set_without_revision_is_bootstrap_only` — bootstrap даёт `rev=1`, повторная запись без rev даёт `conflict` |
| AC2 | Missing-rev conflict не меняет config/rev/backup/event, включая semantic no-op | ✅ | код: `return` на строке 1312 происходит до `msg["config"] == data.get("config")` (no-op-детекция, 1374), до `async_save_config_state`, до `_discard_optimizer_snapshot`, до `hass.bus.async_fire`; тест явно шлёт байт-в-байт совпадающий `first_config` без rev вторым клиентом и проверяет `stored_before` равенство, `OPTIMIZE_BACKUP` неизменность, `config_events == []` |
| AC3 | Два конкурентных клиента: после первого коммита второй blind write отклонён | ✅ | тест `test_issue_340_config_set_without_revision_is_bootstrap_only`: `first_client` коммитит rev=1, `stale_client` (не видевший этот коммит) получает `conflict`; сериализация гарантирована тем же `write_lock`, которым защищены все остальные writer'ы (не новый механизм) |
| AC4 | Explicit верный rev — success/no-op; explicit устаревший rev — `conflict` | ✅ | не задето изменением (строки 1313-1318 без правок) + существующий `test_config_rev_conflict` зелёный + новый тест дополнительно проверяет retry с `expected_rev=1` → `rev=2` |
| AC5 | Ровно один `config/set` во frontend, всегда с `expected_rev` | ✅ | `scripts/coordinate-write-barrier-guard.mjs:39` требует `expected_rev: this\._cfgRev` в том же окне, что `canonicalCandidate`; production `src/houseplan-card.ts:6887-6890` содержит ровно это; `checkCoordinateWriteBarriers()` вручную прогнан → `[]`; новый unit-тест мутирует fixture (убирает `expected_rev`) и подтверждает, что guard это ловит — прогнан, зелёный |
| AC6 | Публичная документация не обещает warning-only, описывает bootstrap-only compatibility | ✅ | `docs/ARCHITECTURE.md` (таблица + новый абзац), `docs/CONFIG-COMPATIBILITY.md` (новый раздел «Revision-less config writers (#340)»), `docs/TESTING.md` (пункт B2-B5 переписан) — все три больше не говорят про warning-only; `node scripts/check-docs.mjs` зелёный |
| AC7 | UI/i18n/schema/model/export version/View/kiosk/touch и позитивный write-path не меняются | ✅ | `git diff --stat` не содержит ни одного файла `src/**`, `custom_components/**/translations/**`, `manifest.json`; позитивный путь (`expected_rev` совпадает) — тот же код, что и раньше (строки 1313 и далее не менялись); `npx tsc --noEmit`/`npm run build` зелёные |
Все 7 AC доказаны автотестом; там, где тест — дисциплина «тест умеет падать»
подтверждена явным прогоном на pre-fix коде (см. таблицу гейтов). AC6/AC7 —
частично «проверено чтением, не исполнением» в части текста документации и
отсутствия diff в защищённых путях, что и требуется для этого типа критерия.
## Находки
Нет ни одной находки уровня High или Medium. Одно наблюдение уровня **Low**,
не требующее правки:
- `docs/ARCHITECTURE.md` в строке таблицы `houseplan/config/set` убрал `?` у
`expected_rev` (было `expected_rev?`), в то время как `houseplan/layout/set`
на строке выше сохраняет `expected_rev?`. На первый взгляд несогласованность,
но это осознанная и корректная асимметрия: `layout/set` действительно
остаётся warning-only (тот самый дефект, вынесенный ревьюером ТЗ в отдельный
#356 как вне скоупа #340), а `config/set` через 3 строки ниже таблицы получает
отдельный абзац, ровно объясняющий, что поле формально `vol.Optional`, но
семантически обязательно везде, кроме bootstrap. Снято без правки — документ
точен, объяснение рядом.
## Что проверено и корректно
- Guard стоит внутри `rt.write_lock`, сразу после `async_load()`/`current_rev`,
до `CONFIG_SCHEMA`/`validate_*` и до сравнения no-op — соответствует §6.2
ТЗ построчно.
- Warning-лог после правки не содержит конфига/имён маркеров: тест явно
проверяет `"stale-secret" not in caplog.text`.
- Bootstrap-исключение безопасно и при гонке двух bootstrap-клиентов: оба
идут через тот же `write_lock`, второй уже видит `rev=1` и попадает в ветку
`conflict` — не новый примитив, тот же, что уже страхует остальные writer'ы
этого файла.
- Ошибка пользователю (`toast.conflict`) не меняется и не начинает показывать
число ревизии карточке: `src/houseplan-card.ts:7068-7070` берёт только
`e.code === 'conflict'` и показывает статичный переведённый текст, `e.message`
(с текстом ревизии для прямого WS-клиента) не парсится и не отображается —
риска «одно число, два источника» это изменение не создаёт.
- Оба changelog (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`) отредактированы в
том же коммите `3e437ca3`, что несёт `User-Visible: yes` — трейлер-требование
соблюдено. `docs: specify config revision enforcement` (`c9981e68`, ТЗ+specs/README)
отдельно несёт `User-Visible: no` — верно, это не продуктовое изменение.
- Никаких файлов класса D (`dist/**`, `custom_components/houseplan/frontend/**`)
в диффе нет — ожидаемо, поскольку `src/**` не менялся вовсе.
- Medium-находка спец-ревью вне скоупа (`layout/set`, тот же класс C4) уже
заведена спек-ревьюером как #356 — не задача этого код-ревью её дублировать
или чинить.
## Вывод
Зелёный. Все 7 AC доказаны и воспроизведены самостоятельно (включая
подтверждение, что новый backend-тест падает на pre-fix коде). Дешёвые и
уместные по диффу гейты — все зелёные. Находок, требующих правки, нет.