Files
houseplan-card/docs/reviews/CODE-REVIEW-291-r1.md
T
2026-08-24 17:13:28 +03:00

30 KiB
Raw Blame History

CODE-REVIEW-291-r1

  • Issue: #291 «Барьер записи координат: убрать шум как явление»
  • Этап: код-ревью (PROCESS.md §2.7), заход r1 (первый заход код-ревью; ревью ТЗ прошло отдельно, 3 захода, зелёное на r3 — сюда не засчитывается, §10.4 «цикл считается по этапу»)
  • Диапазон: 10fa0dc7..81d2a801 (merge-base с origin/dev .. HEAD ветки issue/291-lattice-coordinate-barrier), 10 коммитов, 37 файлов, +2374/-328
  • ТЗ: docs/specs/291-lattice-coordinate-write-barrier.md, зелёное ревью SPEC-REVIEW-291-r3.md
  • Ревьюер: свежая сессия, без контекста реализации; артефакты читаны из ветки, а не со слов автора

Скоуп

Единый барьер записи, приводящий координаты, лежащие ближе 1e-4 шага сетки к узлу k/240, к точному double этого узла — во frontend и backend, в layout и config, с отдельным отчётом Optimize и защитой от обхода. Не входит: смена схемы хранения, #288/#289/#290, union-алгоритм #278, снап авторской off-grid геометрии. Это первый код-ревью раунд — предмет разбора полный, дельты по предыдущему раунду нет.

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

Гейты по PROCESS.md §8, соразмерно задаче (диапазон трогает src/**, custom_components/**/*.py, геометрию и ссылки на неё):

Гейт Команда Результат
Typecheck npx tsc --noEmit зелёный
Unit npm test 1232 passed, 1 skipped, 0 failed
Build + bundle parity npm run build && npm run bundle:sync + cmp трёх копий dist/, custom_components/houseplan/frontend/, demo/srv/assets/ побайтово идентичны
Docs fingerprint node scripts/check-docs.mjs «Documentation checks passed (7 files, 10 external links)» — обязателен, diff трогает src/**
Model invariants node scripts/model-invariants.mjs --config <real-plan-first-floor.json как config> --lattice (сам, до и после canonicalizeConfigGeometry) до барьера: шум 66.15% (127/192), совпадает по порядку с измерением issue (65.38%/78.77%); после барьера: шум 0 (0.00%), законно вне сетки 2 (1.04%) — воспроизводит формат AC3 буквально; второй прогон канонизации байт-в-байт идентичен первому (AC1/AC4 idempotence на живых данных, исполнено, не только прочитано)
Model invariants (violations) node scripts/model-invariants.mjs --config <migrated> «Инварианты выполнены»; 2 наблюдения (exact-endpoint fallback) — не нарушения, не относится к #291
Mutation gate (структурная проверка) node scripts/mutation-gate.mjs --check все 180 патчей (включая 6 новых lattice-мутантов) применяются к текущему коду ровно один раз
Mutation gate (полный прогон, выборочно) --id=lattice-round-truncates, --id=frontend-writes-raw-coords оба: мутант применён → именованный тест покраснел, гейт зафиксировал ловлю. --id=optimize-config-storage-half-raw (backend-мутант, переиспользованный из #248 для «direct backend Store bypass») упал на отсутствии pytest/homeassistant в этой среде — не показатель дефекта, показатель среды (см. «Не проверял»)
Performance (AC11) node demo/benchmark_coordinate_write_barrier.mjs ratio 0.666 (лимит 1.2) — независимый локальный прогон, не только цифра автора (0.823, тоже под лимитом)
Smoke — выбор node scripts/smoke-select.mjs --base origin/dev --head HEAD 3 «прямых совпадения» (cellCm) + 1 «зарегистрированная связь» (repairSpaceReferences) — вывод приложен ниже
Smoke — по AC node demo/smoke_lattice_write_barrier.mjs, node demo/smoke_optimize_coordinate_canonicalization.mjs оба OK, все под-проверки true
Smoke — по выбору smoke_decor, smoke_grid_scale_invariance, smoke_space_scale_defaults, smoke_orphan_space_references все OK
Backend python -m pytest tests_backend/test_coordinate_canonicalization.py -q не выполнено — в среде ревью нет homeassistant (ModuleNotFoundError), см. «Не проверял»
Golden не прогонялся сознательное решение, см. «Не проверял»
Полный набор смоков (182 файла) не прогонялся вне объёма код-ревью (PROCESS.md §8: «полные наборы — предрелизный гейт»)
Process gate node scripts/process-gate.mjs --github-range --issues локально на HEAD «гейт пройден, предупреждений 0»

Вывод smoke-select.mjs:

Изменено файлов src/**: 3 · символов проекта на изменённых строках: 26
Прямое совпадение (3): smoke_decor.mjs ← cellCm; smoke_grid_scale_invariance.mjs ← cellCm;
  smoke_space_scale_defaults.mjs ← cellCm
Зарегистрированная связь (1): smoke_orphan_space_references.mjs ← repairSpaceReferences

Все четыре прогнаны (таблица выше), все зелёные.

CI на ветке — что нашёл и как учёл

Validate на коммите с реализацией (9a42c856, run 32732620838) завершился cancelled, с process-gate: failure и docs: failure; golden/smoke/ performance_smoke не завершились (cancelled), потому что следующий пуш (81d2a801) отменил незавершённый прогон (штатное поведение concurrency.cancel-in-progress, AGENTS.md). Разобрал каждую причину:

  • docs: failure — ожидаемо, отпечаток скриншотов был устаревшим; закрыто тем же следующим коммитом (docs: accept screenshots for lattice barrier);
  • process-gate: failure — git cat-file -e 89f8b581...^{commit} не находит объект: BEFORE_SHA ссылался на коммит, переписанный до этого пуша (амend/форс на личной ветке задачи, что процессом не запрещено). Транзиентная инфраструктурная причина, не нарушение правил — на финальном HEAD process-gate зелёный и в CI (run 32732834660), и в моём локальном прогоне;
  • golden/smoke/performance_smoke для финального HEAD в CI не запускались вовсе — на ветках задач changes-job фильтрует по diff последнего пуша (а не всей ветки), и пуш 81d2a801 тронул только docs/images/**. Это осознанный дизайн (validate.yml: «настоящую приёмку там делает код-ревью, которое гоняет гейты само, #127») — то есть именно код-ревью (эта сессия), а не CI, отвечает за targeted-гейты, что я и сделал выше.

Ссылка автора в хендоффе («Linux backend … зелёный in Validate 32732620838») точна для job backend, но не упоминает, что сам прогон был cancelled и два job упали — это не искажение факта, но неполная картина. Отмечаю как наблюдение, не как находку: обе причины разобраны и не относятся к поведению #291.

AC — построчно

AC Доказательство Вердикт
AC1 Идемпотентность/точные биты test/coordinate-canonicalization.test.mjs: цикл по 4801 узлам, идемпотентность, -0→+0, 0.06/0.2875 не снапятся, граница 1e-4 в обе стороны — плюс мой прогон на живой фикстуре (см. таблицу) ✅ доказано автотестом + исполнением
AC2 Shared allow-list parity одна fixture (test/fixtures/coordinate-canonicalization.json), deep-equal TS/Python (test_python_and_frontend_share_the_scalar_lattice_fixture_contract), input immutable ✅ доказано автотестом (TS выполнен; Python — проверено чтением, см. «Не проверял»)
AC3 Живой профиль после migration = 0 test/model-invariants.test.mjs: real-plan-*.json clone → profile.noise===0, far===measured.far, checkWallKeys/checkMixedRoleRecords = 0, raw file byte-equal до/после — плюс мой независимый прогон CLI на real-plan-first-floor.json (66.15%→0.00%, идемпотентность подтверждена вторым прогоном) ✅ доказано автотестом + исполнением
AC4 Произвольная editing session не возвращает noise см. находку M1 — нет production-bundle smoke, который бы рисовал/продолжал Walls chain, делал Resize, ставил opening/partition/column/decor, двигал marker/room label и проверял latticeProfile.noise===0 после каждого шага, как требует буква AC ⚠️ не доказано ни тестом, ни явной записью «проверено чтением» — структурно вероятно верно (AC5+AC1), но не показано
AC5 Write barrier нельзя обойти test/coordinate-write-barrier-guard.test.mjs (инвентарь всех 4 frontend-writer'ов + 2 backend Store boundary в store.py); мутанты frontend-writes-raw-coords (#224, переиспользован) и optimize-config/layout-storage-half-raw (#248, переиспользован) целятся именно в bypass; первый прогнан мной полностью и покраснел как положено ✅ доказано автотестом (frontend выполнено; backend-мутант не выполнен, см. «Не проверял»)
AC6 Source fixtures остаются шумными test('барьер очищает клоны реальных планов, не переписывая source fixtures (#291)'): readFileSync до/после теста байт-в-байт равны; profile.noise >= 100 пин остаётся ✅ доказано автотестом, выполнено
AC7 Optimize report и transaction smoke_optimize_coordinate_canonicalization.mjs (17/17 true, выполнено мной), test/plan-optimizer.test.mjs (идемпотентность, latticeCoordinatesCanonicalized/Far не путается с coordsCanonicalized, лёгко проверяемый порядок: барьер применяется до alignAllToGrid, поэтому align не видит уже исправленный шум и не задваивает счётчик — проверено чтением plan-optimizer.ts:415-420 и подтверждено тем, что coordsCanonicalized===0 рядом с latticeCoordinatesCanonicalized>0 в тесте «issue 273»/«ULP source») ✅ доказано автотестом + smoke, выполнено
AC8 Compatibility paths test_backend_schemas_apply_the_same_allowlist, optimize-storage-roundtrip.json, 280-optimize-rehost-candidate.json обновлены канонизацией; no schema/version bump подтверждён (модель/сторедж не тронуты) ✅ проверено чтением + существующими тестами (backend часть не исполнена мной)
AC9 Polyclip/#278 regression npm test включает все 5 тестов #278 … (1207-1211), все зелёные; барьер не трогает physical-geometry.ts/union ✅ доказано автотестом, выполнено
AC10 Мутанты 6 новых lattice-мутантов + 2 переиспользованных (#224/#248) закрывают все 6 категорий буквы AC10 (round→truncate TS+Python, threshold too small/large, layout allow-list omission, unknown-field recursion, frontend writer bypass, backend Store bypass). --check зелёный на всех 180; полностью прогнаны мной 2 из 8 релевантных (TS-side); backend-мутант не прогнан (среда) ✅ доказано структурно + частично исполнением, backend — проверено чтением
AC11 Performance demo/benchmark_coordinate_write_barrier.mjs, ratio 0.666 (я) / 0.823 (автор), лимит 1.2; линейный один проход, без второго deep-clone (проверено чтением plan-optimizer.ts: canonicalizeConfigGeometryInPlace работает по месту, autor явно закомментировал это решение) ✅ доказано автотестом/бенчмарком, выполнено
AC12 Локальные гейты typecheck/test/build/bundle-parity/check-docs — выполнены (таблица выше); backend — не выполнен (среда) ✅/⚠️ частично, см. «Не проверял»

Находки

M1 (Medium, в скоупе) — AC4 не доказан ни автотестом, ни явной записью

AC4 требует production-bundle smoke, который последовательно: рисует/ продолжает Walls chain → Resize → ставит/двигает opening/partition/column/ decor → двигает device marker и room label → делает config/layout write, reload, export — и после каждого commit проверяет latticeProfile.noise === 0. Ни один смок в диффе (новый smoke_lattice_write_barrier.mjs, обновлённый smoke_optimize_coordinate_canonicalization.mjs) этого не делает: smoke_lattice_write_barrier.mjs подаёт шумные данные напрямую в card._serverCfg/card._layout и проверяет 4 writer-call-site, минуя контроллеры инструментов (wall chain, resize, opening/partition/column/decor, drag маркера/подписи). Ни одного смока в репозитории, ссылающегося на latticeProfile, я не нашёл (grep -rl latticeProfile demo/*.mjs — пусто).

Структурно AC5 (единый барьер, непроходимый) + AC1 (сама функция корректна) делают результат весьма вероятным без отдельного прогона — но это не то же самое, что показать его для реальных контроллеров, которые формируют кандидат разными путями (snap при рисовании, resize-пересчёт, поворот проёма). Именно ради этой уверенности AC4 сформулирован отдельно от AC5, и именно эту рамку (§7.1: «либо автотест, либо явная запись ревьюера») сейчас не закрывает ни тест, ни хендофф-комментарий автора (там нет фразы вида «AC4 закрыт составом AC1+AC5, отдельный смок не написан»).

Воспроизведение отсутствия: grep -rl "latticeProfile\|noise === 0" demo/*.mjs → пусто; diff не трогает ни один из смоков с реальными жестами (smoke_wall_chain_*, smoke_room_resize, smoke_opening_preview, smoke_free_walls и т.п.).

Правка: либо добавить проверку latticeProfile.noise===0 после committed-пар в один из существующих session-смоков (например, расширить smoke_lattice_write_barrier.mjs реальными жестами через инструменты, а не прямой инъекцией в _serverCfg), либо явно задокументировать в ТЗ/хендоффе, что AC4 закрывается композицией AC1+AC5, и получить с этим согласие ревьюера. Второй путь дешевле и, на мой взгляд, содержательно корректен — но должен быть сказан, а не подразумеваться.

M2 (Medium, в скоупе) — docs/USER-GUIDE.ru.md не обновлён

Спецификация (§13 «Ожидаемые файлы») называет оба файла: docs/USER-GUIDE.md и docs/USER-GUIDE.ru.md. В диффе изменён только docs/USER-GUIDE.md (добавлен абзац о невидимом floating-point хвосте, отчёте Optimize и автоматической защите обычных правок) — git diff origin/dev...HEAD --stat -- docs/USER-GUIDE.ru.md пуст.

RU-версия при этом не «пусто молчит»: раздел 19 уже содержит общее описание похожего механизма (устранение «невидимого вычислительного шума» при обычном сохранении, разделение строк «обновлено» и «шум устранён» в превью), — но это описание существовало до #291 (git blame: 2026-08-20/23, до ветки задачи) и не называет ни нового per-space разбиения (gs.optimize_lattice_space), ни физического максимума с тремя значащими цифрами (formatLatticeShiftCm) — то, что EN-версия получила явно.

Это нарушает не абстрактное «обе версии», а конкретное правило: AGENTS.md — «interface wording comes from docs/USER-GUIDE.ru.md… or the UI starts speaking developer»; PROCESS §2.6 — документация в том же коммите, что поведение; коммит помечен User-Visible: yes и уже поправил оба CHANGELOG (RU+EN) — тот же стандарт должен быть применён и к самому руководству пользователя.

Воспроизведение: git diff origin/dev...HEAD --stat -- docs/USER-GUIDE.ru.md → пустой вывод; сравнить с непустым для docs/USER-GUIDE.md.

Правка: перенести/адаптировать на русский абзац, добавленный в docs/USER-GUIDE.md (раздел 19), с упоминанием per-space строк и физического максимума — по терминологии gs.optimize_lattice_summary/ gs.optimize_lattice_space из src/i18n/ru.json.

Low — не блокируют, зафиксированы без правки

  • L1. Хендофф-комментарий автора цитирует run 32732620838 как «зелёный» для backend-job, не упоминая, что сам прогон cancelled и два job упали (docs, process-gate) — разобрано выше в «CI на ветке», причины не относятся к #291 и с тех пор устранены. Отмечаю как точность цитирования, не как дефект реализации.

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

  • Формула maxShiftCm = shift * LATTICE_GRID_N * cellCm в coordinate-canonicalization.ts не баг: она буквально повторяет уже существующую конвенцию align-grid.ts:185 (d * GRID_N * cellCm) — нормализованные координаты записаны в долях полной ширины плана (NORM_W/GRID_N=240 шагов на ширину), поэтому перевод в см требует именно этого множителя. Проверил перекрёстно чтением обоих файлов.
  • «Одно число — один источник»: formatLatticeShiftCm(r.latticeMaxShiftCm) в диалоге Optimize (houseplan-card.ts:16405-16416) взят из того же OptimizeReport, что и Confirm, и не смешивается с d.cm/gs.align_count (обычный grid-align) — разные ключи, разные строки, разные источники данных (lattice.maxShiftCm vs alignReport.maxShiftCm), явно разведены и в коде, и в документации (CANVAS.md: «kept separate from visible moved/maxShift*»). Единственное смешение — суммарный счётчик в toast gs.align_done (m: ... + d.report.latticeCoordinatesCanonicalized + ...), но это легитимная сумма разнородных счётчиков для одной итоговой фразы, а не повторное отображение одной и той же величины.
  • Порядок операций в optimizePlans(): latticeCanonicalizationReport() считается до canonicalizeConfigGeometryInPlace/ canonicalizeLayoutGeometryInPlace, которые в свою очередь идут до alignAllToGrid — отчёт не искажён последующей мутацией, а align не задваивает то, что уже поправил барьер (числами подтверждено в тесте «Optimize canonicalizes the six-room ULP source…»: coordsCanonicalized===0 рядом с latticeCoordinatesCanonicalized>0).
  • changed (готовность к записи) считается по финальному persistedConfig после lattice-канонизации — поэтому latticeCoordinatesCanonicalized>0 всегда подтверждает changed=true, без риска репортить ненулевые счётчики при changed=false (читал код, логической рассинхронизации не нашёл).
  • Backend-зеркало (coordinate_canonicalization.py): math.floor(scaled + 0.5) корректно воспроизводит tie-breaking JS Math.round (к +∞, включая отрицательные .5) — проверено на нескольких контрольных значениях вручную (-0.5→0, -1.5→-1, -2.5→-2, совпадает с JS). store.py не тронут диффом вообще: расширение поведения происходит через уже существующий единый вызов canonicalize_config_geometry/ canonicalize_layout_geometry внутри async_save_config_state/ async_save_layout_state (оба — из #224), что и есть корректный способ расширить барьер без создания второй точки входа.
  • Гвард scripts/coordinate-write-barrier-guard.mjs: пересчитал руками все 4 frontend-паттерна (config/set ×1, layout/update ×2, localStorage. setItem(LS_KEY ×1) прямым grep по src/houseplan-card.ts — счётчики гварда совпадают с реальным числом occurrences.
  • i18n: оба новых ключа (gs.optimize_lattice_summary, gs.optimize_lattice_space) присутствуют в en.json/ru.json буквально как в §7.1 ТЗ; test/i18n.test.mjs проверяет и текст, и точки вызова в houseplan-card.ts.
  • Трейлеры: все 10 коммитов несут Issue: #291 и корректный User-Visible: yes|no; единственный User-Visible: yes коммит (9a42c856) правит оба docs/CHANGELOG.md/docs/CHANGELOG.ru.md в одном коммите.
  • node scripts/process-gate.mjs --github-range --issues, прогнан локально на HEAD: «гейт пройден, предупреждений 0».
  • Документация подсистемы (CANVAS.md, CONFIG-COMPATIBILITY.md, ARCHITECTURE.md, TESTING.md) содержательно и точно описывает новую границу, терминологию не изобретает, ссылается на реальные имена полей отчёта.

Чего не проверял и почему

  • Backend pytest (tests_backend/test_coordinate_canonicalization.py, включая мутанты python-lattice-round-truncates, optimize-*-storage-half-raw): в среде ревью не установлен homeassistant (ModuleNotFoundError, даже с pytest доустановленным) — известное и задокументированное ограничение (AGENTS.md: «A full Home Assistant harness cannot run on native Windows… locally only the pure subset runs»; здесь и чистый subset не собрался, так как модуль virtual_lights тянет homeassistant.components.frontend уже на импорте). Заменил чтением: TS/Python реализации построчно идентичны по структуре, тест-файл 1:1 зеркалит TS-тест по значениям (те же 4801 узлов, те же граничные случаи), а автор сослался на зелёный backend job в CI (run 32732620838, JSON подтверждён мной через gh run view) — «проверено чтением, не исполнением» для backend-части AC1/AC2/ AC5/AC8/AC10.
  • npm run golden:verify: не прогонял. Diff потенциально меняет видимый Optimize-диалог (новые строки отчёта), но test('модели проекта не несут шума решётки (#282)') (зелёный, в npm test) доказывает, что все demo-фикстуры, включая golden-geometry, не содержат lattice-шума — значит новые строки (${r.latticeCoordinatesCanonicalized ? html\...` : nothing}) в golden-сценариях optimize-preflight-dialog-*не отрenders, и визуального расхождения не должно быть. Не стал тратить на это предрелизный гейт; если ошибаюсь —golden:verify` перед бетой это покажет.
  • Полный набор из 182 браузерных смоков: не прогонял, кроме выбранных 6 (2 по AC + 4 по smoke-select) — по правилу §8 «полные наборы — гейт предрелиза, не код-ревью».
  • Полный HA-харнесс / WSL: недоступен в среде ревью.
  • Не проверял детально совместимость с #290 (уступ 316×1) и #288/#289 — их регрессионные тесты в npm test прошли без изменений в диффе, а сам диф их не трогает; это не отдельная точка риска этой задачи.

Итог

High-находок нет. Две находки Medium, обе в скоупе задачи (обе — про собственные акцептанс-критерии/список файлов ТЗ, не сторонний код) и обе чинятся в этой же задаче без нового issue: M1 (нет production-smoke для буквы AC4 либо явного признания замены на структурное доказательство) и M2 (docs/USER-GUIDE.ru.md не обновлён при User-Visible: yes). Ядро реализации — сама канонизация, барьер записи, backend-зеркало, идемпотентность, защита от обхода, производительность — проверено и работает, включая независимое исполнение на реальных (privacy-minimized) данных из issue.

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