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

17 KiB
Raw Blame History

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