32 KiB
SPEC-REVIEW-186-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/186
- ТЗ под ревью:
docs/specs/186-partition-opening-jamb-margin.md(коммит7d4d3f0), обычный трек — неsmall/trivial - Роль: ревьюер ТЗ (не автор), этап
S4-spec-review - Трек: обычный, лимит циклов ревью ТЗ — 4 (§4 PROCESS.md)
- Цикл: r1/4
Скоуп ревью
ТЗ #186 — follow-up к #132 (найдено на код-ревью CODE-REVIEW-132-r1.md,
Medium-1): вводит содержательный jamb safety margin (половина фактической
толщины independent-стены у каждого торца) для door/window/gate/passage,
размещённых в partition, вместо нынешнего jambMargin=0 в
resolvePartitionOpening() и голого 1e-9-эпсилона в backend. Затрагивает
placement/drag/dialog в Plan editor, frontend commit validation, backend
semantic delta validation (config/set, optimize, import) и compatibility
уже сохранённых near-end проёмов.
Не в скоупе ревью: продуктовый код. Ветка issue/186-partition-jamb-margin
содержит один коммит (7d4d3f0, Issue: #186 · User-Visible: no) — только
спецификация и обновление docs/specs/README.md, реализации нет.
Гейты (typecheck/test/build) не прогонялись: на этапе ревью ТЗ
продуктового кода не существует, прогон к этому этапу не относится
(PROCESS.md §2.4/§8).
Как проверялось
- Прочитаны
docs/SCOPE.md,AGENTS.md,PROCESS.mdцеликом (действующая редакция, включая §1, §2.2–§2.4, §5, §7.1, §8, §10.2). - Прочитано тело issue #186 и все три комментария: аналитика владельца
(оценка 5/10 · 6/10 · 5/10 · P2 ·
bug, обычный трек с явным обоснованием, три вопроса с default), решение владельца (все три default приняты дословно) и хендофф «ТЗ готово к ревью». - Построчно сверены технические утверждения §4 ТЗ с кодом на этой ветке:
resolvePartitionOpening()(src/partition-openings.ts:35-61) — параметрjambMargin = 0подтверждён; проверкаdoes-not-fit(along - length/2 < jambMargin - 1e-9 || along + length/2 > axisLength - jambMargin + 1e-9) действительно единственное место, гдеjambMarginиспользуется.- Продакшн call sites (
src/houseplan-card.ts:7455, 7920, 7953, 11300, 11604, 11613, 17569,src/space-render.ts:214) действительно не передают шестой аргумент — ТЗ не завышает диагноз issue. - Формула §4:
wallDepth = wallCmToUnits(cm, cellCm, gridPitch),jambMargin = wallDepth/2— проверено поwallCmToUnits()(src/wall-thickness.ts:70-73,= (cm/cellCm) * gridPitch) и по фактическому вызову наhouseplan-card.ts:7455-7457, гдеlengthScale=NORM_WиgridPitch=this._gridPitch(геттер возвращаетGRID_PITCH,src/houseplan-card.ts:5771,GRID_PITCH = NORM_W / GRID_N,GRID_N = 240,src/space-geometry.ts:197-201). В render- единицах (NORM_W-масштаб) формула самосогласована сaxisLengthиlength, которые в этом же call site тоже в render-единицах. - Backend-эквивалент §4:
partition.cm / cell_cm / 240 / 2в normalized units (масштаб хранения, 1.0 = ширина канвы) — пересчитан независимо:wallCmToUnitsв render-единицах даёт(cm/cellCm) * (1000/240); деление наNORM_W=1000(переход в normalized) даёт(cm/cellCm)/240, и/2для margin — формула ТЗ подтверждена арифметически, это не придуманная константа, а имеющийсяGRID_N=240(src/space-geometry.ts:197, тот же литерал уже упомянут комментарием вcustom_components/houseplan/validation.py:445, «240 cells across the unit width»). _space_geometry_invariants()(custom_components/houseplan/ validation.py:813-852, встроена вSPACE_SCHEMAчерезvol.All) — подтверждён zero-margin fit-check с1e-9(строка 845), встроенный в структурную схему, а не в semantic-слой — совпадает с диагнозом issue и §8 ТЗ («Структурная SPACE_SCHEMA сохраняет прежний zero-margin fit check»).validate_partition_opening_hosts()(validation.py:55-79) — реально существующий semantic delta validator с сигнатурой(config, previous=None), уже используется вconfig/set(websocket_api.py:1260) и в optimize (websocket_api.py:1372, тот же блок вызываетvalidate_marker_controls/validate_opening_passages/validate_partition_opening_hostsс явным комментарием «Optimization is a normal configuration write with an additional layout transaction. It must enforce the same marker-link semantics as config/set»). Это подтверждает AC6 («config/set и optimize применяют один semantic validator») как реальный, не гипотетический паттерн.
- Прочитан
docs/CONFIG-COMPATIBILITY.mdцеликом, включая уже принятый аналогичный прецедент «Independent-wall opening host (#132)» и «Open- passage opening type (#157)» — оба используют тот же приём (new/changed строго, existing/unrelated толерантно), что и §8 ТЗ #186 предлагает для jamb margin; терминология («legacy», «read-compatibility», «drop-on- validation») не изобретается заново. - Прочитан
docs/USER-GUIDE.ru.md§9 («Двери, окна, открытые проёмы, ворота и замки», строки 428-479) и §20 («Хранение, совместная работа и резервные копии», строки 1207-1244) — терминология диалога («независимая стена», «край проёма», «торец») совпадает с уже принятым текстом; отдельно проверено описание полного экспорта/импорта (см. находку High-1 ниже). - Прочитан
docs/WALL-THICKNESS.md— термин «jamb» (jamb return, строки 94-134) уже канонический для геометрии откоса; «jamb safety margin» ТЗ не вводит новую непонятную лексику, а расширяет существующее семейство. - Проверен полный путь full import (кнопка «Полная копия» из §20
USER-GUIDE), которым оперирует §8 ТЗ:
prepare_apply()(custom_components/houseplan/import_export.py:1304- 1344): приdocument["kind"] == "full"(строка 1330) вызывается толькоCONFIG_SCHEMA(config)(строка 1344) — ниvalidate_marker_controls, ниvalidate_opening_passages, ниvalidate_partition_opening_hostsне вызываются вовсе для этого пути. Сравнить сdocument["kind"] != "full"(строка 1339,build_space_merge), который вызывает semantic-валидаторы, но всегда с новымspace_id(реассайн id, USER-GUIDE строка 1218), из-за чего delta-сравнение по id всё равно никогда не находит «старую» запись.ws_import_apply()(websocket_api.py:365-444) наkind == "full"(строка 426) сохраняет текущийconfig_data.get("config")в_OPTIMIZE_BACKUPметаданных ради одной отмены (can_undo, строка 499) — то есть «предыдущая» версия конфига физически загружена и доступна в этот момент, просто не передаётся ни в один semantic validator.- Итог: на сегодня full import вообще не проверяет partition-opening-
host инварианты семантически — ни downgrade guard, ни (будущий) jamb
margin; единственный барьер — структурная
SPACE_SCHEMAс её zero-margin fit-check. §8 ТЗ вводит для этого пути принципиально новую, а не расширяемую проверку. См. находку High-1.
- Проверено соответствие
docs/SCOPE.md: задача — геометрическая достоверность плана (J4 «zero to a working plan... room polygons bound to areas», J6 «keep the plan true as the home evolves»), персона home admin, поверхность — desktop Plan editor + backend validation; View/kiosk не затрагиваются. Полное резервное копирование/восстановление — тоже прямо часть J6 («keep the plan true») и задокументировано в USER-GUIDE §20 — поэтому конфликт из находки High-1 лежит внутри того же core job, который ТЗ заявляет закрывать, а не в смежной, посторонней функции. - Проверено, что issue не помечен
small/trivial: аналитика владельца явно называет причины (новый видимый граничный контракт, compatibility-решение) — критерии §5 PROCESS.md («нет нового UX- контракта», «нет compatibility-полей») не выполняются, обычный трек обоснован корректно, файл ТЗ вdocs/specs/создан как требуется.
Гейты (typecheck/test/build, browser smoke, golden, backend pytest) не
прогонялись — продуктового кода нет, что и ожидается на этапе ревью ТЗ.
Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1: home admin, Plan editor, размещение/перемещение проёма в independent-стене |
| Что человек увидит до/после | ✅ | §2, одна фраза до/после без терминов реализации |
| Проблема | ⚠️ | Нет отдельного заголовка «Проблема»; содержание фактически есть в §2 «До» и подтверждено issue. Формальный, не содержательный пробел — см. Low-1 |
| Скоуп / не-скоуп | ✅ | §5/§6, конкретны, «доп. зазор между проёмами» и «resize partition» явно исключены |
| Контракт поведения | ✅ | §7 (placement, drag/dialog, перемещение host) + §8 (backend) |
| UX | ✅ | §9, таблица состояний, 8 строк |
| Модель данных и миграция | ✅ | §10: «модель данных и schema полей не меняются» — явно и обоснованно |
| i18n | ✅ | §10, RU/EN строка для banner приведена дословно |
| AC1…ACn с доказательством | ✅ | §11, 6 штук, у каждого назван способ доказательства (unit/backend/smoke) |
| План автотестов | ✅ | §12, разделён на implementation-loop и pre-review гейты |
| Риски | ✅ | §13, 7 строк риск/мера — но не покрывает риск найденного противоречия (High-1) |
| Откат | ✅ | §14, без миграции, возврат к zero-margin |
| Release-артефакты | ✅ | §15, оба changelog, USER-GUIDE, CONFIG-COMPATIBILITY, TESTING |
Присутствует также обязательный блок «Принятые технические предположения»
(§16, 4 пункта) — все технические (rigid translation, type-only edit,
имена helper/reason/i18n key, malformed cm/cell_cm fallback), ни один не
маскирует продуктовый вопрос. Однако именно то, что должно было попасть в
этот блок или в пачку вопросов владельцу — трактовка full import как
«без trusted previous» — вместо этого записано как решённый факт в §5/§8/§11
(AC4). См. находку High-1.
Находки
High-1 — «строгая» проверка full import противоречит уже принятому владельцем round-trip-инварианту и ломает документированный backup/restore
Файл: docs/specs/186-partition-opening-jamb-margin.md, §8 (строки
163-166), §5 (строка 68), AC4 (строки 223-231).
Что заявлено. §8: «Полный import/restore без trusted previous рассматривает все hosted openings как новые и проверяет strict.» AC4 явно фиксирует это в тесте: «Direct geometry edit и full import того же нарушения отклоняются» (строка 228), с доказательством «backend delta/import tests с previous и без него» (строка 230).
Почему это противоречие, а не деталь реализации.
- Owner-decision #3 (принята дословно в комментарии от 2026-08-19 08:02): «Уже сохранённые проёмы, нарушающие новый предел, остаются видимыми и рабочими, не сдвигаются автоматически и допускают неизменённый round-trip при посторонних правках... Новый предел применяется при создании, перемещении, изменении длины или перепривязке.» Ни исходный default аналитика, ни решение владельца ни разу не называют «полный экспорт/импорт» как один из триггеров нового предела — речь только о прямых geometry-действиях над самим проёмом/host.
- «Полная копия» — не гипотетическая, а прямо задокументированная в
docs/USER-GUIDE.ru.md§20 (строки 1207-1244) функция этого же J6 («keep the plan true as the home evolves»,docs/SCOPE.md): «Экспорт предлагает... Полная копия — ...её импорт заменяет текущую модель целиком; до следующего изменения плана доступна одна отмена.» Наличие «одной отмены» — прямое доказательство (websocket_api.py:426-444,_OPTIMIZE_BACKUP), что это рутинная операция на той же живой инсталляции (восстановление вчерашней резервной копии после неудачных правок), а не только перенос на чистый новый инстанс. - Полный импорт не переприсваивает id (
import_export.py:1330-1344, веткаkind == "full":config = imported_configбез ремаппинга, в отличие отspace-merge, который явно генерирует новые id — USER-GUIDE строка 1218). Значит id пространств/проёмов в восстановленном файле буквально совпадают с id, под которыми они хранились в момент экспорта. - Итог сценария: администратор на v1.65.0-beta.2 создал проём впритык к
торцу independent-стены (валидно на тот момент). После обновления до
версии с #186 он делает «Полная копия» (рутинный совет из
docs/USER-GUIDE.ru.mdперед крупной правкой, строка 1358) — экспорт содержит этот проём без изменений, потому что по decision #3 он остаётся «видимым и рабочим». Позже (после неудачного эксперимента, или после переустановки HA, или просто ради восстановления) он импортирует тот же самый, ничем не изменённый файл обратно. По §8 ТЗ и AC4 этот импорт обязан быть отклонён ровно из-за геометрии, которая мгновение назад была признана допустимой для чтения/рендера/экспорта. Это прямое нарушение decision #3 «допускают неизменённый round-trip» и очень вероятный сценарий (единственное действие пользователя — сделать и восстановить резервную копию, ничего не редактируя). - Технически ничего не мешало сохранить round-trip хотя бы для случая
«restore на ту же инсталляцию»:
ws_import_apply()уже загружает текущийconfig_data.get("config")в этот самый момент (используется как backup для отмены, строка 429) — тот же объект мог бы быть передан в semantic validator какprevious, как это уже делается дляconfig/set(websocket_api.py:1260) и optimize (websocket_api.py:1372). Но даже это не спасает случай disaster-recovery на пустой инсталляции (переустановка HA,config_data.get("config")пуст) — там по любому id-based delta-подходу «предыдущего» совпадения не найдётся, и restore старого валидного бэкапа всё равно будет отклонён. Это структурное ограничение самой архитектуры full-import (нет модели «that record is the same one, unchanged»), а не деталь одной реализации — то есть вопрос принципиально не имеет тихого технического решения и должен решаться владельцем. - Формулировка §8 предъявлена как принятое решение, а не как assumption: она не попала ни в блок §16 «Принято предположительно», ни в пачку вопросов владельцу, хотя по своей природе — это ровно тот тип вопроса, который согласно PROCESS.md §7.1 обязан идти владельцу: «поведение в пограничном случае» и «что считать приемлемой деградацией». Оценка аналитика (Touch/Performance были явно взвешены) не рассматривала этот write-path вовсе.
Последствие для процесса. Утверждение о поведении (full import — strict), которого нет ни в одном согласованном документе и которое противоречит уже зафиксированному владельцем decision #3, подано как факт. Это ровно тот класс дефекта, который PROCESS.md называет «худшим видом» — предположение, записанное как решение, потому что оно read как решение и прошло бы имплементацию буквально по AC4.
Требуемое действие. Автор обязан вынести это отдельным продуктовым вопросом владельцу (в формате §7.1: что неясно · что изменится от ответа · default), например:
Full import/restore того же полного бэкапа, содержащего legacy near-end proём — тоже вариант «посторонней правки» с сохранением round-trip (decision #3), или отдельный, более строгий путь, где такой бэкап нужно будет сначала починить? Default: round-trip сохраняется всегда, когда id пространства/проёма совпадает с уже хранимой записью (сравнение с текущим
config_data.get("config"), который и так загружается для undo-бэкапа); при restore на пустую/чужую инсталляцию (нет совпадения по id) — new/strict, как сейчас предложено.
Это High: находка блокирует переход в S5-ready, поскольку задевает уже
принятое владельцем решение и заложена в AC4 буквально — реализовать ТЗ как
написано значит намеренно сломать документированный сценарий backup/restore
для любого дома с legacy near-end проёмом.
Low-1 — нет отдельного раздела «Проблема»
Файл: docs/specs/186-partition-opening-jamb-margin.md, между §1 и §3;
ближайшее место — §2 «До / после» (строки 13-22).
PROCESS.md §7.1 перечисляет «проблема» отдельным обязательным разделом. Содержательно проблема изложена — в §2 «До» и в теле issue («Практическое следствие: дверь/окно/ворота можно поставить в перегородку впритык к самому её концу, без зазора на откос»), но отдельного заголовка/абзаца «Проблема» в файле ТЗ нет. Ни один AC от этого не становится неоднозначным.
Решение ревьюера: Low, не блокирует. Снимаю без возврата ТЗ на правку; рекомендую добавить короткий явный абзац «Проблема» при следующей правке файла (тот же цикл, что закроет High-1), а не отдельным циклом ревью.
Что проверено и корректно
- Физическая формула margin (§4 ТЗ) арифметически верна и не выдумана —
проверена независимо через
wallCmToUnits(),GRID_PITCH/GRID_N=240(src/space-geometry.ts:197-201) на фронтенде и пересчитана в normalized- масштаб для backend; итоговая backend-формулаcm/cell_cm/240/2совпадает с ТЗ буквально, включая уже присутствующий в кодовой базе литерал240(комментарийvalidation.py:445). - Диагноз issue подтверждён кодом:
jambMargin=0по умолчанию, production call sites его не передают (partition-openings.ts:41, 8 реальных call sites), backend fit-check использует только1e-9(validation.py:845) — ТЗ не завышает и не занижает масштаб проблемы. - AC6 («config/set и optimize — один semantic validator») подтверждён
реальным существующим паттерном:
validate_partition_opening_hosts()уже вызывается из обоих путей с явным комментарием о необходимости одинаковой семантики (websocket_api.py:1260, 1372). - §8 корректно описывает существующее устройство структурной схемы:
_space_geometry_invariants()действительно встроена вSPACE_SCHEMAчерезvol.Allи продолжит принимать zero-margin legacy-геометрию, если её саму не менять — ТЗ явно требует именно это («Структурная SPACE_SCHEMA сохраняет прежний zero-margin fit check»). - Терминология соответствует канону: «независимая стена», «торец»,
«край проёма» — из
docs/USER-GUIDE.ru.md; «jamb» как основа термина — из уже принятогоdocs/WALL-THICKNESS.md. Диалоговое сообщение (RU/EN) не изобретает новую лексику для пользователя (само слово «jamb» наружу не выходит). docs/CONFIG-COMPATIBILITY.md-паттерн выбран корректно для описанных путей (config/set, optimize): «new/changed strict, unrelated tolerant» — тот же приём, что уже принят для #132 (host) и #157 (passage) в этом же документе; никакой новой персистентной модели/поля не вводится, что соответствует §10 ТЗ и Rule №1 AGENTS.md (класс A без новых compatibility-полей не требует отдельного решения о миграции).- AC1, AC2, AC5 однозначны и снабжены допустимым способом доказательства
(pure unit matrix, backend parametrized tests, browser smoke), включая
точную границу (equality valid) и матрицу
cm=1/15/100— тестируемо и не расплывчато. - Touch-декларация присутствует буквально: «Touch editor: best effort /
intentionally degraded» (§10) — соответствует
docs/TOUCH-SUPPORT.mdDocumentation rule, без нового жеста и с явным safety floor (tap/pinch/ pointercancel). - Откат (§14) реалистичен: без persisted-полей нет обратной миграции; просто удаление strict-политики и семантического валидатора.
- §16 «Принятые предположения» не маскирует продуктовый вопрос — все
четыре пункта (rigid translation,
type-only edit, публичность имён, malformed cm/cell_cm) действительно технические и не требуют решения владельца сами по себе.
Чего не проверял
- Реализацию — её нет: ветка содержит один документный коммит (
7d4d3f0), продуктовый код (src/**,custom_components/houseplan/**/*.py) не менялся — проверено чтением diff коммита и содержимого текущих файлов (resolvePartitionOpening,validate_partition_opening_hosts,_space_geometry_invariants,prepare_applyне тронуты этой веткой). - Гейты
typecheck/test/build/browser smoke/golden/pytest tests_backend— не относятся к этапу ревью ТЗ; предмет будущего код-ревью (PROCESS.md §2.4/§8). - Все восемь текущих production call sites
resolvePartitionOpening()построчно — проверены три (houseplan-card.ts:7455, и по номерам строк для остальных сверено только наличие вызова черезgrep, не полный контекст каждого) и одно место вspace-render.ts:214; для целей ревью ТЗ этого достаточно (диагноз issue про default-параметр не зависит от конкретного call site), но при код-ревью потребуется пройти все. - Численную точность оценок аналитики (5/10 · 6/10 · 5/10 · P2) по существу — поле владельца (PROCESS.md §2.2), уже принятое явным решением до написания ТЗ.
- Совместимость found-High-1 сценария с другими фичами полного импорта
(missing-plan/attachment проверки,
model_versionrestore) — не относится к предмету находки, эти проверки независимы от partition-opening host semantics.
Вердикт
Красный. High: 1 (§8/AC4 «full import — всегда strict» противоречит уже
принятому владельцем round-trip-инварианту decision #3 и ломает
задокументированный в docs/USER-GUIDE.ru.md §20 сценарий backup/restore
для любого дома с legacy near-end проёмом; не помечено как assumption,
подано как решённый факт). Medium: 0. Low: 1 (отсутствует отдельный
заголовок «Проблема» — не блокирует, снимается с рекомендацией). Остальная
часть ТЗ технически точна: физическая формула margin проверена независимым
пересчётом и совпадает с реальными константами кода (GRID_N=240), AC1/AC2/
AC5/AC6 однозначны и снабжены допустимым доказательством, compatibility-
подход для config/set и optimize соответствует уже принятому в проекте
паттерну. Возврат в S3-spec: автору нужно вынести found High-1 отдельным
батчем вопросов владельцу (по шаблону §7.1 — что неясно · что изменится ·
default) прежде чем ТЗ сможет получить зелёное ревью.
Вердикт: красный · цикл r1/4 · High: 1 · Medium: 0 → нет · Документ: docs/reviews/SPEC-REVIEW-186-r1.md