17 KiB
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 стоит до обеих веток (
_normnormalized и legacyelse), то есть закрывает и 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