14 KiB
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(base356b187e, 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 коде). Дешёвые и уместные по диффу гейты — все зелёные. Находок, требующих правки, нет.