Files
houseplan-card/docs/reviews/SPEC-REVIEW-403-r1.md
T
2026-09-01 15:08:22 +00:00

265 lines
25 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.
# SPEC-REVIEW-403-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Артефакт ТЗ: `docs/specs/403-area-relocation-safety.md` (полный трек, класс A;
без метки `small`/`trivial`)
- Материал: SHA `1f9d9014` (ветка `issue/403-area-relocation-safety`,
коммит «docs: specify area relocation safety (#403)»)
- Заход: r1 · лимит циклов ревью ТЗ для полного трека — 4 (§4), израсходовано
до этого раунда — 0 (пять предыдущих запусков конвейера падали до первого
обращения к модели — `is_error: true`, `modelUsage: {}` — вердикта не было
ни разу, бюджет не тратили, см. комментарии issue)
- Ревьюер: Claude (роль «ревьюер ТЗ»), независимая сессия, без устных
пояснений автора
- Первый раунд — разбор полный, раздела «дельта» нет (§2.10 применяется
начиная со второго захода)
## Скоуп ревью
Bug P1, класс A, полный трек (issue не помечен `small`): две находки
свежего кода #126 на одной поверхности (`src/houseplan-card.ts`,
`_syncAreaRelocations` и обработка отказов):
- **C2 (High из аудита)** — отказ `houseplan/config/set` во время переезда
area оставляет `layout` уже удалённым (удаление успело пройти раньше) и
не восстанавливает позицию и не помечает устройство как требующее
внимания — ручная расстановка маркера теряется молча, самовоспроизводяще
(снапшот откатывается на старую area → следующий authoritative-проход
снова решает `relocate`).
- **M1 (Medium из аудита)** — переезд area **любого** устройства чистит
**весь** стек Undo позиций (`_devicePositionHistory.clear()`), включая
записи устройств, которых переезд не касался, и без уведомления (в
отличие от соседнего класса очистки истории — `history.device_stale`).
Задача ложится на J6 `docs/SCOPE.md` («Keep the plan true as the home
evolves» — оптимистичная блокировка, ручная расстановка маркеров) и на
стоящее правило SCOPE.md «никогда не удалять данные пользователя по
догадке» — обе находки именно про это: ручная позиция маркера — данные,
введённые пользователем руками.
## Как проверялось
1. Прочитаны целиком `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1, §2.4,
§2.5, §4, §5, §6, §7.1, §7.2, §8), тело issue #403 и все восемь
комментариев (включая последовательность из шести неудачных запусков
конвейера — учтено при подсчёте бюджета циклов, ни один из них вердикта
не дал).
2. Прочитан весь текст ТЗ `docs/specs/403-area-relocation-safety.md`.
3. Каждое фактическое утверждение ТЗ о коде сверено построчно с деревом на
`1f9d9014`:
- `_maybeRebuildDevices`/`_syncAreaRelocations`
(`src/houseplan-card.ts:5016-5254`) прочитаны целиком;
- подтверждено: ветка отказа **удаления** layout восстанавливает позицию
(`applyDevicePlacement(before)`, фактическая строка `:5184`, ТЗ называет
`:5183` — расхождение в одну строку, не искажает факт);
- подтверждено: ветка отказа **записи конфига** (`catch` на `:5223-5248`,
ТЗ называет тот же диапазон точно) восстанавливает только
`marker_area_snapshot`/`new_device_ids` в памяти (если фингерпринт не
разошёлся) и **не восстанавливает** удалённую позицию — заявление ТЗ
подтверждено буквально, строка в строку;
- подтверждён механизм самовоспроизведения: `resolveDeviceAreaRelocations`
(`src/device-area-relocation.ts:181-188`) решает `relocate = true`
ровно когда `previous.area !== area`; после откатa снапшота на старую
area это условие снова истинно на следующем authoritative-проходе —
цикл, описанный в ТЗ, воспроизводится логикой резолвера, а не является
догадкой;
- подтверждена находка M1: `_devicePositionHistory.clear()`
(`src/houseplan-card.ts:5062-5065` — ТЗ называет `:5056-5058`,
расхождение в 6 строк, см. находку L1) стоит под условием «есть хоть
одно переезжающее устройство», без фильтра по `deviceId`;
- `CommandStack` (`src/command-stack.ts`) не имеет метода выборочного
удаления — подтверждено, что контракт AC5/AC6 («снимается история
переехавших, а не весь стек») требует нового метода, но это техническая
деталь реализации, а не пробел ТЗ (стек типизирован по
`NamedCommand<DevicePositionState>`, `deviceId` уже есть в каждой
записи — технически осуществимо без изменения формата данных);
- `toast.pos_save_failed`/`toast.cfg_save_failed`/`toast.conflict`/
`history.device_stale` — все четыре ключа существуют в `src/i18n/ru.json`
(и en/de/fr) — заявления ТЗ о «существующей метке» и «существующем
уведомлении» подтверждены, не придуманы;
- `registryFollowingBinding`/формат `binding` (`device-area-relocation.ts:95-107`)
— подтверждён формат `${bindingKind}:${bindingRef}`, ровно то, что автор
сам называет причиной трёх неудачных попыток воспроизведения C2 до
финального успешного прогона (комментарии аналитики) — воспроизведение
не голословно, ошибка автора зафиксирована и исправлена явно.
4. Проверено соответствие терминологии `docs/USER-GUIDE.ru.md`: раздел
«Устройства» (`:757-771`) документирует именно тот успешный путь, который
AC2 требует сохранить («старая позиция удаляется… появляется красная
отметка внимания»), и раздел «История редактора» (`:260`, `:808-809`)
подтверждает существующий контракт Undo (50 команд, best effort на touch) —
спецификация не вводит новых терминов и не противоречит гайду.
5. Проверено `docs/TOUCH-SUPPORT.md` и DoR-чек-лист §2.5 на обязательные
пункты «влияние на touch» и «влияние на производительность» — см. находку
H1.
6. Проверены обязательные разделы §7.1 — присутствуют все (сценарий · что
человек увидит до/после · проблема и контракт по каждому пункту · скоуп/
не-скоуп · UX · модель данных и миграция · i18n · AC1–AC7 с доказательством ·
план автотестов · риски · откат · release-артефакты).
7. Проверено существование инструментов, на которые ссылается план тестов:
`scripts/mutation-gate.mjs` есть, ни одного мутанта `area-relocation-*` в
нём пока нет (согласуется с ТЗ — это новые мутанты); `demo/smoke_area_relocation.mjs`
существует и уже умеет мокать отказ `houseplan/config/set`
(`rejectKettleRelocation`, строки 19/262/279) — план AC1/AC4 технически
реализуем на существующей инфраструктуре смоков, не является фантазией.
8. Гейты кода (`tsc`, `test`, `build`) не гонялись: на этапе ТЗ продуктового
диффа нет (класс C — только `docs/specs/**`), гонять их не над чем.
## Находки
### H1 (High, блокирует, в скоупе). ТЗ не называет два обязательных пункта DoR — влияние на touch/kiosk и на производительность
**Где**: весь файл `docs/specs/403-area-relocation-safety.md` — ни разу не
упоминает touch, kiosk, `TOUCH-SUPPORT.md`, производительность, перф или
бюджет (`grep -i "touch|kiosk|перф|производительн|performance"` по файлу —
ноль совпадений).
**Почему это находка, а не формальность**. PROCESS.md §2.5 перечисляет
обязательные пункты «Готово к разработке» и требует по каждому явную
запись, а не молчание: «влияние на производительность и бюджеты названо
(**или явно «нет»**)» и «влияние на touch по `docs/TOUCH-SUPPORT.md`
(**View и киоск — блокирующие**)». Оба пункта — из списка, помеченного
«Все пункты обязательны», и: «Если хоть один пункт не выполнен — статус не
«Готово к разработке», как бы ни хотелось начать». Ни один из двух пунктов
в ТЗ не назван — ни утвердительно, ни отрицательно.
Это не абстрактная бумажная претензия: у задачи есть настоящая View/kiosk
грань. AC2 фиксирует видимый на любом клиенте (включая киоск-планшет — J1
`docs/SCOPE.md`, «Show the whole home … device states») эффект успешного
переезда — маркер оказывается в новой комнате с отметкой внимания; это
именно то поведение, что описано в `docs/USER-GUIDE.ru.md:761-767`.
Симметрично, дефект C2 в необработанном виде **тоже виден на киоске** —
маркер молча возвращается в центр комнаты. То есть эта ветка кода реально
затрагивает View/kiosk-наблюдаемое поведение, а не только редакторский
слой, и именно поэтому DoR требует явного заявления, а не тишины.
AC5/AC6 (сужение очистки Undo) относятся к редактору устройств, для
которого touch уже документирован как best effort (`USER-GUIDE.ru.md:260`,
`:808-809`) — но это тоже должно быть **названо**, а не молчаливо
унаследовано: без явной строки нельзя отличить «автор сверился с
TOUCH-SUPPORT.md и решил, что контракт не меняется» от «автор не думал про
touch вовсе» (тот же аргумент, которым в SPEC-REVIEW-402-r1 было обосновано
идентичное H1-заключение для #402 — прецедент этого же ревьюера на этом же
проекте).
Фактическая оценка по существу (для экономии цикла): последствий, скорее
всего, нет ни для touch, ни для перфа. Обе правки — (1) порядок операций в
одном async-методе `_syncAreaRelocations` плюс восстановление/повторная
попытка записи при отказе, (2) точечный фильтр по `deviceId` в
уже существующей структуре истории на 50 записей. Ни жесты, ни рендер, ни
DOM, ни сетевые вызовы сверх уже выполняемых не меняются. Но это вывод
ревьюера, а не факт, зафиксированный автором в ТЗ, — фиксировать обязан
автор.
**Требуемая правка** (несколько строк текста, не кода): добавить в ТЗ,
например —
- `Touch: не затронут — правка меняет порядок серверной записи
(_syncAreaRelocations) и фильтр очистки Undo-стека по deviceId, не
касается drag/tap-жестов, рендера или DOM; наследует существующий
контракт Undo/Redo (best effort на touch, USER-GUIDE §10). View/kiosk:
наблюдаемый эффект (AC1/AC2) — позиционный, не входной, контракта View на
touch не меняет.`
- `Производительность: нет — правка не добавляет новых циклов, подписок или
сетевых вызовов сверх уже выполняемых `_syncAreaRelocations`/`_writeConfig`;
фильтр истории работает на существующем массиве максимум 50 записей.`
### L1 (Low, снимается с записью). Номер строк для сниппета M1 отстал от кода на SHA `1f9d9014`
**Где**: `docs/specs/403-area-relocation-safety.md`, раздел «(2) M1»:
«`src/houseplan-card.ts:5056-5058`».
**Проверено чтением**: на `1f9d9014` этот диапазон (`:5056-5058`) — три
строки середины вызова `resolveDeviceAreaRelocations` (`model:`, `layout:`,
`snapshot:` — параметры объекта опций), не имеющие отношения к M1. Сам
процитированный в ТЗ сниппет (`this._areaRelocationIds = new
Set(...); if (...) { this._cancelDeviceDrag(); this._devicePositionHistory.clear();`)
дословно совпадает с кодом, но находится на строках `:5062-5065`.
Содержание находки верно и не пострадало (сверено выше, в «Как
проверялось», п.3), только адрес неточен — вероятно, из-за смещения при
правках между тем, когда аналитика собирала цитату, и фиксацией ТЗ.
**Решение ревьюера**: не блокирует, не создаёт отдельного цикла. Снимаю с
записью — исправить номера строк можно попутно при правке по H1 (тот же
файл будет открыт на редактирование), отдельного возврата ради одной этой
находки не требуется.
## Что проверено и признано корректным
- **Диагноз C2 точен и воспроизводим**: ветка отказа удаления восстанавливает
layout (`:5184`), ветка отказа записи конфига — нет (`:5223-5248`); порядок
«delete-first» (`:5147` комментарий «Layout deletion is deliberately
completed before provenance advances») подтверждён и корректно процитирован.
Самовоспроизводящийся цикл (снапшот откатывается → резолвер снова решает
`relocate`) подтверждён логикой `resolveDeviceAreaRelocations`
(`device-area-relocation.ts:181-188`), а не выдан за факт без опоры на код.
- **Диагноз M1 точен**: `clear()` действительно безусловен по всему набору
переезжающих устройств, `history.device_stale` действительно существует как
образец уже принятого в проекте паттерна уведомления об очистке истории.
- **Два допустимых исхода C2 (запись первой / восстановление при отказе)
сформулированы как решаемая ревьюером/автором техническая развилка**, а не
как догадка, выданная за факт — с явным критерием выбора (AC3, свойство
delete-first) и явной эскалацией в §"Риски", если восстановление тоже
отказывает («потеря неизбежна» → AC1 формулируется как «позиция ИЛИ
метка», не «позиция всегда»). Это корректное использование блока
«принято предположительно» из §7.1 AGENTS.md для чисто технических решений.
- **AC1–AC7 однозначны и указывают способ доказательства** (браузерный смок,
для AC7 — уже существующий `demo/smoke_area_relocation.mjs`). Способ
реалистичен: существующий смок #126 уже умеет мокать отказ `config/set`
(`rejectKettleRelocation`), новый смок под #403 — органичное расширение
того же приёма, не изобретение с нуля.
- **Скоуп/не-скоуп корректен и не пересекается** с #126 (критерии переезда,
формат снапшота — не трогаются), #74/#397 (механика Undo как таковая — не
трогается, трогается только объём очистки), #406 «г» (гигиена снапшотов
исчезнувших устройств — не относится к этой находке).
- **Соответствие `docs/SCOPE.md`**: закрывает J6 (оптимистичная блокировка,
ручная расстановка маркеров) и защищает от нарушения стоящего правила
«никогда не удалять пользовательские данные по инференсу» — задача не
расширяет продукт, а чинит потерю уже введённых пользователем данных.
- **i18n-раздел корректно условен**: если решение обходится существующей
меткой внимания — новых строк нет; если потребуется отдельное уведомление —
явно предписано добавить ключ во все четыре словаря. Не оставляет открытого
вопроса без явного правила на оба исхода.
- **Откат и release-артефакты адекватны масштабу**: обе правки локальны
(один метод, один фильтр), changelog User-Visible: yes корректно назван
(пользователь увидит, что расстановка переживает отказ записи).
- **Метки issue** (`bug`, `P1`, `S4-spec-review`, без `small`/`trivial`)
согласуются с заявленным в ТЗ полным треком — задание корректно определило
входной трек.
- **Инфраструктурная переписка issue** (шесть провалившихся прогонов
конвейера, `is_error`/`modelUsage: {}`) не образует циклов ревью:
вердикта не было ни в одном из них, бюджет §4 остаётся 0 до этого раунда.
## Чего не проверял
- **Гейты кода** (`npx tsc --noEmit`, `npm test`, `npm run build`,
`check-docs`, смоки, инварианты модели) — не гонялись: на этапе ТЗ
продуктового кода нет, диффа для гейтов не существует (диапазон
`origin/dev..HEAD` состоит только из `docs/specs/403-area-relocation-safety.md`).
Это предмет код-ревью после реализации.
- **`scripts/mutation-gate.mjs` / `demo/smoke_area_relocation.mjs` —
запуск**: не запускал ни то, ни другое; только убедился построчным чтением,
что оба файла существуют, а механизм мока отказа WS (`rejectKettleRelocation`)
в существующем смоке технически совместим с планом автотестов ТЗ (AC1/AC4).
- **Реальный браузерный повтор репродукции C2/M1** — на этапе ТЗ кода
нет; воспроизведение, описанное автором аналитики (лог WS-вызовов,
состояние `layoutHasDevice`/`snapshotAreaNow`/`attention`/`undoAvailable`),
принято на основании сверки с логикой резолвера и обработчика отказа в
текущем дереве (см. «Как проверялось», п.3), а не путём собственного
запуска браузера — независимая браузерная перепроверка будет предметом
код-ревью, когда появится смок.
- **`CommandStack`-реализация выборочного удаления** — не проектировал и не
требовал конкретного API; отметил только, что текущий тип данных
(`NamedCommand<DevicePositionState>` с `deviceId` в каждой записи) делает
контракт AC5/AC6 технически осуществимым, выбор метода — за реализацией.
- **Таблицу `docs/specs/README.md`** — строка для #403 в неё не добавлена;
это известный, не относящийся к этой задаче долг (§7.3 п.1 PROCESS.md),
не поднимаю отдельной находкой.
## Вывод
Диагноз и контракт по обеим находкам аудита (C2, M1) точны, построчно
сверены с кодом на `1f9d9014` и не содержат догадок, выданных за факт; AC1–
AC7 однозначны, доказуемы и реалистичны на существующей тестовой
инфраструктуре. Единственная блокирующая находка — процедурная (H1):
ТЗ не называет обязательные по DoR §2.5 пункты про touch/kiosk и
производительность. По существу риска в обоих пунктах, скорее всего, нет,
и правка — несколько строк текста; возвращаю жёлтым, не красным.