Files
houseplan-card/docs/reviews/SPEC-REVIEW-248-r1.md
T
2026-08-22 22:02:13 +00:00

15 KiB
Raw Blame History

SPEC-REVIEW-248-r1

  • Issue: #248 — «Оптимизировать» не идемпотентна после записи и reload
  • ТЗ: docs/specs/248-optimize-idempotence.md, ветка issue/248-optimize-idempotence, коммит 4d73d031a477c06c5b28366bd9e3883ebec421f1
  • Этап: spec (PROCESS.md §2.4), заход r1, трек обычный (не small)
  • Вердикт: зелёный

Скоуп ревью

Первый заход — разбор полный, разделов «Закрытие раунда» и «Унаследовано» нет (PROCESS.md §2.10 применяется со второго цикла).

Проверено по порядку из инструкции:

  1. docs/SCOPE.md — сценарий закрывает J6 («Keep the plan true as the home evolves»); действие administrative/desktop-first, View/kiosk не задеты.
  2. AGENTS.md, PROCESS.md §7.1, §2.4, §2.5 — обязательные разделы ТЗ, классы файлов, критерии DoR.
  3. Тело issue #248 и оба комментария (аналитика владельца, хендофф автора ТЗ).
  4. docs/USER-GUIDE.ru.md — раздел «Что делает оптимизация» (строки 1360–1409), терминология «сдвинуто/устранён шум/повторный Optimize».
  5. Канонический документ подсистемы — docs/CANVAS.md §9.5, плюс docs/CONFIG-COMPATIBILITY.md раздел «Canonical geometry on write (#224)».

Как проверялось

Ревью состязательное: ТЗ читалось без устных пояснений автора, каждое техническое утверждение ТЗ (§3 «Подтверждённая причина», §6 «Контракт поведения», §13 «Принятые предположения») сверялось с актуальным кодом на HEAD, а не принималось на слово.

Прочитан код:

  • src/coordinate-canonicalization.ts (полностью) — allowlist и формула canonicalizeNumber (floor(|v|·10⁹+0.5)/10⁹, COORDINATE_DECIMALS = 9).
  • src/plan-optimizer.ts (полностью) — optimizePlans(): changed вычисляется как JSON.stringify(config) !== original, где original = JSON.stringify(configIn), и нигде не пропускается через canonicalizeConfigGeometry/canonicalizeLayoutGeometry. Это подтверждает корневую причину ТЗ буквально: pure-результат snapN() (шаг 1/240, двоичное число) и то, что вернёт config/get после девятизнакового round-trip storage-контракта #224, — два разных JSON-представления одного и того же логического значения.
  • src/align-grid.ts (основная часть) — snapN(), GRID_STEP_N, EPS, перечень grid-bound/wall-bound элементов (room poly/rect, room_drafts, partitions, wall_columns, decor, openings) — совпадает с матрицей AC2.
  • custom_components/houseplan/websocket_api.py:1554-1666 (ws_plan_optimize) — pending["config"]/pending["layout"] канонизируются явно (canonicalize_ config_geometry(msg["config"])), а фактическая запись идёт через async_save_config_state() / async_save_layout_state().
  • custom_components/houseplan/store.py:150-229 — оба метода канонизируют геометрию внутри себя (layout_store_payload → canonicalize_layout_ geometry, async_save_config_state → canonicalize_config_geometry) независимо от того, что передал вызывающий код.
  • custom_components/houseplan/__init__.py:184 (startup finisher, optimize_pending) — путь recovery, на который ссылается AC3, существует.
  • test/plan-optimizer.test.mjs, test/align-grid.test.mjs, test/fixtures/coordinate-canonicalization.json + test/coordinate-canonicalization.test.mjs + tests_backend/test_coordinate_canonicalization.py — подтверждают, что паттерн «одна JSON-фикстура, читаемая Node и Python независимо, без вызова одного рантайма из другого» (§13.2 ТЗ) уже используется в проекте для ровно той же канонизации, а не изобретается заново.

Вывод по существу: исходная гипотеза issue («сырая запись против канонизированного pending») в актуальном dev не подтверждается — ровно как и написал автор в аналитике и в ТЗ §3. Обе фактические записи (async_save_config_state, async_save_layout_state) канонизируют геометрию сами, независимо от вызывающего кода. Настоящая причина — несовместимость двух корректных по отдельности числовых контрактов (шаг сетки 1/240 не представим точной десятичной дробью, storage округляет её до 9 знаков), и именно она воспроизводится по коду. ТЗ не выдаёт догадку за факт: раздел §13 явно маркирует технические решения как «принято предположительно», а корневая причина подтверждена чтением, а не заявлена.

Разделы §7.1 — проверка полноты

Все обязательные разделы присутствуют и не пусты: сценарий (§1) · что человек увидит до/после (§2) · проблема/причина (§3) · scope/non-scope (§4–5) · контракт поведения (§6) · UX/i18n/touch (§7) · модель данных и совместимость (§8) · AC1…AC6 с доказательством (§9) · план автотестов (§10) · риски/perf/ security/rollback (§11) · release-артефакты (§12) · принятые технические предположения (§13). Продуктовых вопросов владельцу нет — по инструкции такое допустимо, когда ожидаемое поведение уже зафиксировано (здесь — docs/CANVAS.md §9.5 и текущий текст USER-GUIDE.ru.md, см. ниже).

AC — однозначность и способ доказательства

AC Однозначен? Доказательство названо и выполнимо?
AC1 Да — «changed:false, нулевые счётчики, deep-equal» проверяемо программно unit + mutation guard на «убрать финальную канонизацию» — файл test/plan-optimizer.test.mjs существует, фикстуры названы
AC2 Да — конкретная матрица geometry-типов и cell_cm unit parameterized + существующий test/align-grid.test.mjs + mutant «вернуть сырой 1/240»
AC3 Да — exact-equal pending/final/recovery pair backend pytest, startup finisher (__init__.py:184) реально существует
AC4 Да, включая явную оговорку «без Node subprocess из pytest» (§13.2) общая fixture по прецеденту coordinate-canonicalization.json; парность фикстур — новый, но понятный guard
AC5 Частично составной (browser smoke + чтение кода Undo lifecycle), но каждая половина названа отдельно targeted smoke с mocked WS + explicit «проверено чтением» для Undo — соответствует §18 PROCESS.md
AC6 Да, стандартная формулировка typecheck/unit/build + bundle parity; golden/smoke/perf — предрелizный гейт по правилу «полные наборы — не гейт ревью»

Ни один AC не содержит скрытого домысла о поведении, которого нет в канонических документах: контракт §6.1–6.4 — прямое следствие уже существующего docs/CANVAS.md §9.5 («every pass is idempotent») и docs/CONFIG-COMPATIBILITY.md («A repeated canonical Save … is a no-op»), только явно распространённое на write/reload boundary, которого раньше не было в явном виде.

Находки

Блокирующих (High) находок нет. Находок Medium в скоупе или вне скоупа нет.

Low — не блокирует, оставлено на усмотрение автора

  1. §12 ТЗ формулирует обновление docs/USER-GUIDE.ru.md так, будто факт идемпотентности сейчас не документирован, а строка 1380 текущего USER-GUIDE.ru.md уже утверждает «Повторный Optimize над результатом ничего не предлагает» — то есть документ уже обещает то поведение, которое чинит эта задача, только без явного упоминания границы reload/server-event. Не искажает контракт и не создаёт риска для AC — при реализации это, вероятно, точечное уточнение одной фразы, а не новый раздел. Снимается автором по факту правки; фиксирую, чтобы это не создало неверного впечатления при код-ревью, что документация была неверна и её было нужно переписывать заново.

Что проверено и корректно

  • Корневая причина (§3 ТЗ) подтверждена чтением actual dev, а не унаследована из первоначальной (опровергнутой) гипотезы issue.
  • Контракт §6 не противоречит docs/CANVAS.md §9.5 и docs/CONFIG-COMPATIBILITY.md — расширяет их на write/reload boundary.
  • Non-scope (§5) корректно исключает изменение шага сетки, точности канонизации #224, миграций и визуальных изменений — задача не расширяет скоуп бага в рефакторинг.
  • Тестовый план (§10, AC1–AC4) ссылается на реально существующие файлы (test/plan-optimizer.test.mjs, test/align-grid.test.mjs, test/fixtures/coordinate-canonicalization.json) и на реально существующий прецедент общей Node/Python-фикстуры, а не на гипотетический паттерн.
  • Backend-путь (ws_plan_optimize, async_save_config_state, async_save_layout_state, startup finisher) существует ровно там, где ТЗ на него ссылается, с теми же именами функций.
  • Release-артефакты (§12), rollback (§11) и i18n/touch (§7) — не создают новых миграций, флагов или UI-контрактов; согласуется с оценкой сложности 4/10 и User-Visible: yes для будущего implementation-коммита.
  • Роли и трейлеры: коммит 4d73d03 несёт Issue: #248 и User-Visible: no (корректно — это документация ТЗ, не поведение); автор ТЗ и ревьюер — разные модели (Codex/Claude), правило PROCESS.md §6 соблюдено.

Чего не проверял

  • Не запускал никаких гейтов (typecheck/test/build) — на этапе spec-review предмет проверки текст ТЗ и его соответствие коду/документам, а не implementation; кода поведения ещё нет (класс A файлов в диффе этого коммита нет — только docs/specs/**).
  • Не проверял custom_components/houseplan/validation.py:600 (_dedupe_open_spans), упомянутый в исходном issue как альтернативная гипотеза (нормализация проёмов на записи) — ТЗ явно отводит эту гипотезу (§3: «предложенная в исходном описании причина... не подтверждается») и не включает её в scope; сама эта альтернатива не стала предметом контракта, поэтому её код не требовался для проверки полноты ТЗ.
  • Не оценивал производительность реализации (нет кода) — риск-таблица §11 ТЗ содержит явную оценку (O(n) copy-on-write), достаточную для DoR.
  • Не проверял i18n-файлы построчно — ТЗ утверждает «новых строк нет», что проверяемо тривиально на этапе код-ревью через diff src/i18n/*.json.

Итог

ТЗ выполнимо, каждый AC проверяем и снабжён способом доказательства, корневая причина подтверждена по коду, а не по доверию к автору или к первоначальной (опровергнутой) гипотезе issue. Скоуп не расширяется и не сужается относительно реального дефекта. Единственная находка — Low, не блокирует переход в «Готово к разработке».

Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0