Files
houseplan-card/docs/reviews/CODE-REVIEW-184-r1.md
T
2026-08-19 01:50:18 +00:00

161 lines
17 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-184-r1 — fail-closed guard в `_savePos()` против stale space id
- **Issue:** https://github.com/Matysh/houseplan-card/issues/184
- **Трек:** `trivial` — AC зафиксированы в теле issue (аналитика владельца 2026-08-19), спек-файла
нет и не требуется.
- **Диапазон:** `origin/dev..HEAD` = один коммит `fa01590` («fix: reject stale space position
writes»)
- **Цикл:** r1/4
- **Роль:** ревьюер кода (Claude), свежая сессия, без контекста реализации Codex
## 1. Скоуп
Единственный коммит, класс A+B+D:
- `src/houseplan-card.ts` — в `_savePos(d, x, y)` добавлена одна строка
`if (!this._spaceModelById(d.space)) return;` перед веткой `_norm`/legacy и перед мутацией
`this._layout`, `this._dirtyPos.add`, `this._persistLayout()`;
- `scripts/mutation-gate.mjs` — новый мутант `stale-space-position-guard-removed`, удаляющий
именно эту строку и требующий покраснения нового под-теста;
- `test/optional-space-model-contract.test.mjs` — существующий тест `stable space ids use exact
lookup and abort before side effects` расширен проверкой порядка подстрок внутри тела
`_savePos`: `_spaceModelById(d.space)` встречается раньше `this._layout =`, `_dirtyPos.add`,
`_persistLayout()`;
- `docs/TESTING.md` — формулировка чек-листа расширена с «marker persistence validates its
target» до «marker/position persistence validates its target»;
- `docs/images/screenshots.json` + `docs/images/06-device-editor.png` — механическая
ре-фиксация `sourceFingerprint`/`sourceSha256` (привязан к хешу `src/`, любой коммит класса A
его двигает) и одного PNG;
- три копии бандла (`dist/`, `custom_components/houseplan/frontend/`, `demo/srv/assets/`).
Это ровно короткий трек AC1–AC3 из тела issue: fail-closed guard на единственной оставшейся
persist-границе (`_savePos`), которую #113 не закрыл (закрыл `_livePos`, `_vacPlanRoomAnchors`,
`_vacStartFit`, `_labelMove`, `_rlResizeMove`, `_saveMarker`, но не сам `_savePos`). Соответствует
J6 SCOPE.md («Keep the plan true as the home evolves» — drag/resize, optimistic locking, multi-client
sync): фикс закрывает latent-баг, при котором pointer-событие от устаревшего `DevItem` (после
удаления/переименования одного из нескольких этажей у другого клиента) могло записать позицию в
уже неактуальное пространство.
`User-Visible: no` — корректно: путь достижим только при гонке (WS-удаление/переименование
пространства между рендером и отпусканием указателя), не при обычном взаимодействии; changelog не
трогается, и это правильно.
## 2. Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | OK, без ошибок |
| Unit | `npm test` | `887/887`, совпадает с заявленным в хендоффе |
| Build + 3 копии бандла | `npm run build`, затем `sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | OK, все три `2e0d3a41…` — байт-в-байт совпадение |
| Мутационный чек якорей | `node scripts/mutation-gate.mjs --check` | `ok stale-space-position-guard-removed` + все прежние якоря живы |
| Мутационный guard AC1 | `node scripts/mutation-gate.mjs --id=stale-space-position-guard-removed` | `поймано 1 из 1` — чистый прогон зелёный, мутант (удалённая строка guard) красит именно новый под-тест, т.е. тест **умеет падать** |
| Именованный smoke (AC2) | `node demo/smoke_grid_snap.mjs` | OK, все 13 подпроверок `true` |
| Именованный smoke (AC2) | `node demo/smoke_drag_bounds.mjs` | OK, все 12 подпроверок `true` |
| Регрессия соседней поверхности (empty-space lifecycle, которую #113 уже покрывал и которую этот диф касается по TESTING.md) | `node demo/smoke_optional_space_model.mjs` | OK, все 9 подпроверок `true` |
| Docs/screenshot fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| Process gate | `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` |
| Единственный изменённый PNG — реальная разница | `PIL.ImageChops.difference` между `origin/dev` и `HEAD` версиями `docs/images/06-device-editor.png` | bbox `(1141,18)-(1142,19)` — 1 пиксель, канал G отличается на 1 (17 vs 18) — антиалиасинг-шум перезахвата, не смысловое изменение |
**Не прогонялось, и почему:**
- **Полный набор из 127 браузерных смоков** — diff меняет одну функцию (`_savePos`), которая
вызывается из двух мест (`_pointerMove` — drag устройства, `_labelMove` — drag подписи
комнаты); оба покрыты названными в AC2 смоками плюс `smoke_optional_space_model` (соседняя
space-lifecycle поверхность, которую и трогает TESTING.md-правка). Остальные ~124 смока не
затрагивают ни `_savePos`, ни space-selection — не прогонялись, сознательное решение
(PROCESS §8), не молчаливый пропуск.
- **`npm run golden:verify`** — не прогонялся. Guard добавлен *перед* существующей логикой и
срабатывает только когда `_spaceModelById(d.space)` возвращает `undefined`, то есть когда
`d.space` не входит в текущую `_model` — при исправной модели (любой обычный рендер, любой из
golden-сценариев) это невозможно: `d.space` для реальных устройств строится в момент сборки
`DevItem[]` через `resolveDeviceSpace`/аналог с фоллбэком на `firstSpaceId` (см. `src/devices.ts:999-1012`),
так что для непустой модели `d.space` всегда указывает на существующее пространство (проверено
чтением). Видимый пиксельный результат обычного drag/label-move не меняется — подтверждено
дополнительно исполнением `smoke_grid_snap`/`smoke_drag_bounds` (AC2), которые как раз проверяют
геометрию после сохранения позиции.
- **`python -m pytest tests_backend`** — не тронут ни один файл `custom_components/**/*.py`.
- **Performance-профили** — не названы в AC; изменение — одна O(1)-проверка (`Array.find` по
обычно короткому списку пространств) в начале уже существующего обработчика, не hot render
path. Не прогонялись.
## 3. Проверка AC (тело issue #184, короткий трек)
| AC | Доказательство | Статус |
|---|---|---|
| AC1 — stale write является no-op: `_savePos()` при отсутствующем `d.space` не меняет `_layout`/`_dirtyPos` и не вызывает persist | `test/optional-space-model-contract.test.mjs` — расширенный под-тест проверяет позиционный порядок подстрок в теле `_savePos`: `_spaceModelById(d.space)` строго раньше `this._layout =`, `_dirtyPos.add`, `_persistLayout()`; **подтверждено исполнением**, что тест умеет падать — `node scripts/mutation-gate.mjs --id=stale-space-position-guard-removed` → `поймано 1 из 1` (мутант убирает ровно guard-строку) | доказано unit + mutation-тестом (исполнение) |
| AC2 — valid movement сохраняется: для существующего space координаты/grid/clamp/scale работают как раньше для устройства и room label | `node demo/smoke_grid_snap.mjs` (13/13 true, включая grid snap на устройстве и подписи) и `node demo/smoke_drag_bounds.mjs` (12/12 true, включая `markerSurvivesRebuild`/`labelSurvivesRebuild`) — оба лично перезапущены на пересобранном бандле, зелёные | доказано browser smoke (исполнение) |
| AC3 — selector discipline остаётся структурной: `_spaceModel()` без id; известные call sites на `_spaceModelById()`; source-contract включает `_savePos` в перечень | тот же расширенный под-тест плюс ранее существовавшие проверки в файле (`_livePos`, `_vacPlanRoomAnchors`, `_vacStartFit`, `_labelMove`, `_rlResizeMove`, `_saveMarker`) — не тронуты этим диффом, но перепрогнаны вместе (`npm test` зелёный целиком) | доказано unit-тестом (исполнение) |
## 4. Дисциплина «тест умеет падать» — что лично проверено исполнением
- `node scripts/mutation-gate.mjs --id=stale-space-position-guard-removed` реально применяет
патч (`if (!this._spaceModelById(d.space)) return;\n if (this._norm) {` →
`if (this._norm) {`), пересобирает тестовый TS и гоняет ровно
`--test-name-pattern="stable space ids"` — вывод: `stale-space-position-guard-removed: тест
покраснел, как обязан`. Не «предположительно ловит» — подтверждённая красная реакция на живом
прогоне.
- `node scripts/mutation-gate.mjs --check` подтверждает, что якорь патча (`find`) встречается в
`src/houseplan-card.ts` ровно один раз — патч ложится туда, куда должен, а не куда попало.
## 5. Находки
Нет находок High. Нет находок Medium.
**Low-1** (не блокирует, снимается с записью). Правило §4 `docs/TESTING.md` («тест, охраняющий
механизм, сопровождается мутантом») реализовано технически (мутант `stale-space-position-guard-removed`
добавлен и работает), но чек-лист-пункт «Empty-space lifecycle (#113)» на строке 40-43, который
этот коммит редактирует (расширяет формулировку с «marker persistence» до «marker/position
persistence»), продолжает ссылаться только на `[unit: optional-space-model-contract.test.mjs]` и не
называет новый мутационный id — в отличие от соседнего пункта строкой ниже
(`[mutation: empty-space-cleanup-disabled]`), который следует этому же паттерну документирования
пофайлово. Косметическое расхождение с собственным соглашением документа, не влияет на
работоспособность гейта (`mutation-gate.mjs --check` его всё равно видит и исполняет). Не требует
отдельного issue — можно поправить одной строкой в следующем touch этого файла или прямо сейчас
автором до мержа, на усмотрение.
## 6. Что проверено и корректно
- Guard стоит **до** обеих веток (`_norm` normalized и legacy `else`), то есть закрывает и
grid-snapped, и pixel-legacy путь одинаково — проверено чтением diff, строка вставлена перед
`if (this._norm) {`.
- Оба call site `_savePos` учтены: `_pointerMove` (line ~5609, drag устройства) не имел
предварительной проверки `d.space` — реальная дыра, которую и закрывает issue; `_labelMove`
(line ~16951-16964) уже содержит собственный `_spaceModelById(spaceId)` guard с `return` до
вызова `_savePos` — новый guard там избыточен, но безвреден (defense in depth, как и заявлено
автором в хендоффе).
- `d.space` — обязательное поле `DevItem.space: string` (не optional), и для реальных устройств
строится с фоллбэком на `firstSpaceId` в момент сборки списка устройств
(`src/devices.ts:999-1012`) — то есть при непустой модели и без гонки `d.space` всегда валиден;
guard не может сработать на обычном путём и не ломает штатный drag (подтверждено smoke AC2).
- `docs/images/screenshots.json`: `sourceFingerprint`/`sourceSha256` пересчитаны для всех 10
сценариев (ожидаемо — привязаны к хешу всего `src/`, а не к конкретной функции), обновлён
ровно один `imageSha256` (`device-editor`). Разница в самом PNG — 1×1 пиксель, отличие в 1
единицу канала G (17 против 18) — проверено попиксельно через `PIL.ImageChops.difference`,
визуального регресса нет, это шум перезахвата, не следствие правки поведения.
- Коммит `fa01590` несёт `Issue: #184` и `User-Visible: no`; изменений в `docs/CHANGELOG*.md` нет
и не требуется — путь недостижим при обычном взаимодействии.
- `node scripts/process-gate.mjs --issues` зелёный на этом диапазоне.
## 7. Чего не проверял
- Полный набор из 127 браузерных смоков и `golden:verify` — обоснование сужения в §2 (diff
затрагивает ровно один метод с двумя call sites, оба покрыты названными в AC смоками).
- `pytest tests_backend` — класс A/B бэкенда не тронут.
- Performance-профили — не названы в AC, hot-path не тронут (обоснование в §2).
- Не воспроизводил вручную саму гонку (WS-удаление пространства во время активного pointer-drag
другого устройства) в браузере/Playwright — только чтением кода и порядком инструкций в
source-contract; ручного тестирования в цикле ревью нет по процессу, а сценарий требует
многоклиентской постановки вне текущего smoke-набора. Логический разбор (см. §6) и
доказательство «guard строго до side effects» через unit+mutation считаю достаточным для
latent-race фикса такого размера.
## 8. Вердикт
AC1–AC3 доказаны: AC1 и AC3 — unit/mutation-тестом с подтверждённой способностью падать
(исполнение), AC2 — двумя названными в AC браузерными смоками (исполнение). Гейты typecheck/test/
build/bundle-sync зелёные, process-gate зелёный, docs-fingerprint зелёный. High: 0. Medium: 0.
Одно Low-наблюдение (несогласованность чек-листа `TESTING.md` со своим же соглашением
`[mutation: …]`) снимается с запиской, без нового issue.
**Вердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0 → нет · Документ: docs/reviews/CODE-REVIEW-184-r1.md**