Files
houseplan-card/docs/reviews/CODE-REVIEW-282-r1.md
T
2026-08-26 02:20:57 +03:00

28 KiB
Raw Blame History

CODE-REVIEW-282-r1

  • Issue: #282 — стабильная идентичность сегментов стен (ADR Stage 1)
  • Ветка: issue/282-wall-geometry-model, HEAD 5adfdb5d (после ребейза на origin/dev f2460dc8)
  • ТЗ: docs/specs/282-stable-wall-segment-identity.md (spec-review зелёный на r2, 2f30c481)
  • Этап: код-ревью (PROCESS.md §2.7)
  • Заход: r1 · блокирующих циклов израсходовано 0 из 4 до этого вердикта
  • Вердикт: красный

1. Скоуп проверки

Диапазон: git diff origin/dev...HEAD (62 файла, +3641/−337), т.е. весь рабочий цикл Stage 1 — persisted wall_segments[] catalog, rooms[].wall_ids[], tagged opening host, единый identity barrier commitWallSegmentModel/ commitWallSegmentModelInPlace, backend-зеркало (wall_segment_model.py, validation.py), миграция v7→v8, compatibility-проекция walls[], документация, i18n, тесты (unit/backend/invariants/benchmark/mutation-gate/golden).

Это не локальная правка — это ревью полной первой реализации основного этапа задачи, поэтому разбор велся полным объёмом (PROCESS.md §2.10: «разбор остаётся полным, если ... задета новая подсистема» — здесь затронута каждая структурная операция геометрии стен). Раздел «Унаследовано из r(N−1)» не нужен: это первый заход код-ревью.

2. Как проверялось — таблица гейтов

Гейт Команда Результат
Typecheck npx tsc --noEmit зелёный
Unit/frontend npm test 1321 тестов, 1320 passed, 1 skipped, 0 failed
Build + sync bandle npm run build && npm run bundle:sync + cmp трёх копий зелёный, dist/, custom_components/houseplan/frontend/, demo/srv/assets/ побайтово идентичны
Docs fingerprint node scripts/check-docs.mjs зелёный (7 файлов, 10 внешних ссылок) — обязателен, диф трогает src/**
Model invariants npm run invariants -- --config <large-house.mjs, до и после миграции> зелёный на pre-v8 и post-v8 конфиге; расширенные v8-проверки (room_wall_ids, opening_host→wall) в scripts/model-invariants.mjs реально добавлены и сработали
Migration benchmark npm run benchmark:wall-model зелёный: 10 000 атомов, p95 208–284 мс (бюджет 500 мс), passed:true
Smoke selection node scripts/smoke-select.mjs --base origin/dev --head HEAD 29 прямых совпадений, 6 зарегистрированных связей, 7 слабых, из 190 всего — вывод приложен ниже (§4)
Golden npm run golden:verify красный: 2 из ~140 сценариев — optimize-orphan-references-dark-en, optimize-orphan-references-light-ru (diffRatio 0.085/0.094 при пороге 0.0008)
Named/registered smokes 6 «зарегистрированная связь» + 28 «прямое совпадение» (все, кроме уже покрытых) 2 красных из 6 зарегистрированных, 7 красных из 28 прямых — см. §4
Backend pure не прогонялся мной (см. §6, «чего не проверял») author указывает 146 passed/1 skipped локально на Windows
pytest tests_backend (полный, Linux) не прогонялся пре-релизный гейт, вне объёма код-ревью (§8)
Performance-профили (full performance_smoke) не прогонялся пре-релизный гейт

Дешёвые гейты все зелёные. Расширенные (invariants, benchmark, smoke-select, golden, целевые smoke) прогнаны, потому что diff меняет геометрию/ссылки на неё и видимый результат (AC15 добавляет строку в диалог Optimize) — именно то сочетание, для которого PROCESS.md §8 требует их «по необходимости». Именно эти прогоны, а не дешёвые гейты, и нашли блокирующие находки ниже.

3. High — блокирующие находки

H1. #278-guard «отклонить правку деградированного пространства» больше не срабатывает

Файл: demo/smoke_wall_union_isolation.mjs (не тронут этим диффом — 0 строк в git diff --stat origin/dev...HEAD), корень вероятно в src/houseplan-card.ts:7261-7307 (_commitPhysicalGeometry).

Воспроизведение: тест не изменён этим диффом. На origin/dev (f2460dc8, собран отдельным git-worktree, тот же npm run build && npm run bundle:sync):

node demo/smoke_wall_union_isolation.mjs → OK (все 11 проверок true)

На HEAD (5adfdb5d), дважды подряд, детерминированно:

"degradedPhysicalEditRejected": false      (ожидалось true)
"rejectedEditHasNoHistoryOrWrite": false   (ожидалось true)
"rejectedEditHasLocalizedToast": false     (ожидалось true)
FAILED (3)

Сценарий смока (issue #278): на фикстуре с уже деградированным canonical-union (status: 'degraded-extra') прямая правка толщины стены (walls[0].cm += 1) должна быть отклонена _checkSpacePhysicalGeometry без записи в историю и без WS-записи, с тостом toast.geometry_unsafe. Сейчас writes !== 0 и/или _geometryHistory.size !== 0 — то есть правка прошла и была сохранена, хотя пространство остаётся геометрически некорректным (то самое, что #278 явно запрещает).

Это прямое попадание в риск, названный самим ТЗ (§16): «Новая identity используется для удаления старых geometry guards» — и в AC12: «Точный ID не является поводом удалить существующую union isolation либо ослабить geometry preflight». Похоже, что перестройка wall_segments/walls[] внутри commitWallSegmentModel (переписывает space.walls целиком из catalog, wall-segment-model.ts:482-486) меняет вход, который видит _checkSpacePhysicalGeometry в _commitPhysicalGeometry (строка 7287), и старая деградированная конфигурация после прохождения через барьер перестаёт распознаваться как небезопасная — либо fingerprint-guard на строках 7279-7284 ("no-op" ветка) срабатывает раньше, чем должен. Это не разобрано до конца — это находка «оно не работает», а не диагноз; автору нужно трассировать _commitPhysicalGeometry на этой фикстуре.

Доказательство: тест умеет падать (см. воспроизведение — падает на HEAD, проходит на dev с байт-идентичным файлом теста).

H2. Широкая регрессия структурных операций редактирования

Файлы: восемь именованных demo-smoke, ни один не тронут этим диффом (git diff --stat подтверждён для каждого — 0 строк).

Все восемь зелёные на origin/dev (проверено тем же способом — отдельный git-worktree на f2460dc8, идентичный собранный бандл) и красные на HEAD, детерминированно (перепроверено вторым прогоном для двух из них):

Smoke Упавшая проверка Что означает
smoke_resize_pointer_real_plan.mjs both_rooms_commit_ten_steps, wall_metadata_preserved (24 записи толщины → 29), undo_byte_exact Resize на реальном 8-комнатном плане плодит лишние атомы вместо чистого частичного split; Undo не восстанавливает byte-exact исходный конфиг — возвращает раздутый v8-catalog вместо исходного v7-документа
smoke_wall_chain_thickness.mjs legacyResumedFullVector продолжение (resume) ранее сохранённой цепочки стен теряет полный вектор толщины
smoke_unified_wall_tool.mjs cleanSplitRetainsParentMetadata, cleanSplitConsumesDivider «чистый» split цепочки не сохраняет метаданные родителя и не поглощает divider — то самое split-lineage поведение, которое AC4 требует доказать
smoke_wall_face_overlap.mjs partialOverlapKeepsRoomsUntilDecision частичное перекрытие граней не удерживает комнаты до явного решения пользователя
smoke_edit_walk.mjs walk_first_floor_seed1/2/3 (все три) fuzz-walk (случайные ресайзы) на фикстуре real-plan-first-floor.json расходится с ожидаемым инвариантом на каждом из 3 сидов; real-plan-second-floor.json не задет
smoke_editor_tabs.mjs tabCrossLimitKeepsDraftWithFeedback лимит на cross-стены при рисовании перестал сохранять черновик с обратной связью
smoke_plan_snap_overlay.mjs openingGapDoesNotActivateSnap снап-оверлей активируется там, где не должен (зазор проёма)
smoke_partition_openings.mjs tooShortPartitionHasNoPreviewAndExplainsWhy, hostBodyHasFullDepthOpeningGap, rigidHostMoveKeepsTAndMovesProjectionAtomically предпросмотр слишком короткой перегородки, полнота глубины проёма и атомарный перенос host+projection при жёстком перемещении — все три ломаются

Доказательство: все девять (H1 + 8 из этой таблицы) — тесты, которые умеют падать: зафиксирована пара «зелёный на dev / красный на HEAD» на байт-идентичном файле теста для каждого. Это не флуктуация — wall_union_isolation и partition_openings перепроверены повторным прогоном с тем же результатом.

Из 34 релевантных по smoke-select.mjs смоков (6 «зарегистрированная связь» + 28 «прямое совпадение», кроме уже покрытых в реестре), не прогнаны только 7 «слабая связь, одно распространённое имя» (_path) — они не были приоритетными на фоне уже найденных 9 красных, но и не гарантированно зелёные; см. §6.

Итог по H1+H2: это прямой ответ на обязанность ревьюера «оно вообще работает» (PROCESS.md §2.7). Ответ — нет, не в текущем виде. AC3 (identity не зависит от геометрии), AC4 (split/merge lineage), AC6 (opening host), AC8 (read-only/atomic failure), AC12 (regression floor) заявлены как доказанные именно этими и смежными смоками/автотестами, но конкретно эти девять доказательства не проходят на предъявленном коде. Обе находки блокируют (High), проверять глубже уже не требуется по методологии — предъявленных детерминированных воспроизведений достаточно, чтобы вернуть задачу автору.

4. Medium — по существу задачи (чинятся в этом же issue, #202)

Ниже сохранены для одного цикла ревью: даже после устранения H1/H2 эти находки останутся, и лучше не резать возврат на несколько дублирующих раундов.

M1. AC15 добавил новую строку в Optimize-диалог — golden baseline не обновлён

npm run golden:verify (не тронут этим диффом: demo/golden/matrix.mjs и harness.mjs — 0 строк в diff) красен на двух существующих сценариях: optimize-orphan-references-dark-en (diffRatio 0.0845 при пороге 0.0008), optimize-orphan-references-light-ru (0.0940). Разница — ровно новая строка диалога «Wall segments stabilised: 25.» / «Стабилизировано сегментов стен: 25.» (AC15, gs.wall_segments_migrated), подтверждено визуально по artifacts/golden/diff/optimize-orphan-references-dark-en.png и actual/…png. Изменение видимого результата — ожидаемое и правильное, но §18 ТЗ и PROCESS.md §3.13 требуют принятия нового эталона только через npm run golden:accept -- --reviewed на полном Linux CI-артефакте; сейчас demo/golden/baselines/** не тронут диффом вовсе.

M2. AC5 «executable source/AST guard + bypass mutant for each writer family» реализован уже, чем заявлено

test/wall-segment-model.test.mjs:191-205 — не AST, а построчный indexOf/regex по двум именованным методам (_commitPhysicalGeometry, _applyGeometryState), проверяющий, что каждый содержит нужный вызов. Это разумный choke-point тест, но не «перечисление всех входов записи», как заявлено в AC5 и повторено в docs/TESTING.md («Structural writers are enumerated by scripts/mutation-gate.mjs»). В самом scripts/mutation-gate.mjs (строки 2875-2887) — ровно один мутант, откатывающий вызов в _commitPhysicalGeometry; для вызова в _applyGeometryState (Undo/Redo restore, строка ~7397 диффа) и для вызова в src/plan-optimizer.ts:656 (commitWallSegmentModelInPlace внутри optimizePlans) — никакого мутанта нет. Гипотетический новый метод-писатель, минующий оба известных choke-point и вызывающий _writeConfig напрямую, не будет пойман ни тестом, ни mutation-gate — то есть именно тот сценарий, который явно называет AC5 («новый путь записывает … партиции … мимо barrier»), не покрыт механически. Формулировку в docs/TESTING.md и/или объём guard нужно привести в соответствие.

M3. wall_model_client_outdated не срабатывает в реалистичном сценарии старого клиента

validate_wall_model_transition (custom_components/houseplan/validation.py:138-155) поднимает WallModelClientOutdatedError только когда old_model >= 8 and new_model < 8 — то есть только если клиент явно откатил model_version. Но §10.2 ТЗ описывает другой, более реалистичный случай: старый клиент сохраняет model_version: 8 (просто эхом), не трогает wall_segments/wall_ids, но меняет poly/walls/openings. Этот случай ловит независимая _config_wall_segment_invariants (validation.py:1718-1808, часть CONFIG_SCHEMA, применяется декоратором websocket_command до тела ws_config_set) — но она поднимает обычный vol.Invalid, не WallModelClientOutdatedError, поэтому код ошибки не wall_model_client_outdated. Фронтенд показывает дружелюбный тост только по точному совпадению кода (src/houseplan-card.ts:7499, :15746: e?.code === 'wall_model_client_outdated'). Итог: частичная запись всё ещё не проходит (безопасность данных сохраняется), но пользователь вместо «обновите карточку и перезагрузите страницу» увидит общую ошибку схемы. tests_backend/test_wall_segment_model.py:145-168 покрывает только узкий explicit-downgrade вариант, не этот.

M4. Регрессионный тест issue #248 ослаблен без обновления фикстуры

test/plan-optimizer.test.mjs, тест «issue 248 Optimize stays a no-op across the lattice storage round-trip» (диапазон AC12: #197/#224/#249/…/#288-302 включает идемпотентность Optimize):

-      assert.deepEqual(first.config, storageRoundtripFixture.expected.config);
+      assert.equal(first.config.model_version, PLAN_MODEL_VERSION);
+      assert.ok(first.config.spaces.every((space) => Array.isArray(space.wall_segments)));
+      assert.deepEqual(
+        first.config.spaces.map((space) => space.rooms.map((room) => room.poly)),
+        storageRoundtripFixture.expected.config.spaces.map((space) => (
+          space.rooms.map((room) => room.poly)
+        )),
+      );

test/fixtures/optimize-storage-roundtrip.json не тронут этим диффом (0 строк) — его expected.config остался в v7-форме и не отражает реальный v8-выход, поэтому полное побайтовое сравнение стало технически невозможным без правки фикстуры и было заменено сравнением только rooms[].poly. Побочный эффект: тест больше не проверяет byte-parity wall_segments/wall_ids/walls[]-проекции для этого именованного регрессионного сценария — то самое ослабление, которое AC12 запрещает как побочный эффект.

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

  • Migration determinism/idempotence — test/wall-segment-model.test.mjs (детерминированный hash-seed, суффиксы -2/-3 при коллизии, идемпотентность повторного commitWallSegmentModel) читал и прогонял; тесты реальные (проверял deterministicWallSegmentId вручную по независимо вычисленному SHA-256/base32) — совпадает.
  • Backend-зеркало валидации (_config_wall_segment_invariants, validation.py:1718-1808) — независимая от фронтенда проверка unique-id, owner-count 1/2, dangling refs, edge-geometry parity, walls[]↔wall_segments parity, opening host fit/overlap; прочитано полностью, соответствует AC2/AC11.
  • Import/export remap (import_export.py, build_space_merge) — remap всех id идёт через secrets.token_hex(4), никогда через геометрическое совпадение; соответствует AC10 и явному запрету ТЗ («collision с target никогда не resolve-ится геометрическим совпадением»).
  • i18n — новые ключи toast.wall_model_migration_blocked, toast.wall_model_client_outdated, gs.wall_segments_migrated, wall_model.reason.* присутствуют и совпадают в en/ru, с одинаковыми плейсхолдерами.
  • Документация — WALL-THICKNESS.md, ARCHITECTURE.md, CONFIG-COMPATIBILITY.md, CANVAS.md, TESTING.md, USER-GUIDE.{ru,}.md обновлены содержательно и точно описывают новую модель; USER-GUIDE.ru.md/.md используют «стабильный внутренний идентификатор» только в контексте объяснения автоматического обновления старого плана — соответствует исключению AC16, не течёт в обычный UI.
  • config-field-registry.mjs — wall_segments, rooms[].wall_ids, room_drafts[].segments[].id, openings[].host=wall зарегистрированы как current v8, с описанной миграцией/совместимостью.
  • Performance — npm run benchmark:wall-model реальный (не no-op, синтетическая геометрия 10 000 атомов), p95 208-284 мс < 500 мс бюджета.
  • Единственный источник числа — толщина теперь имеет один источник (wall_segments[].cm), walls[] — регенерируемая проекция, backend проверяет их точное соответствие построчно (validation.py:1752-1766); test/single-source-numbers.test.mjs не тронут этим диффом и продолжает покрывать форматирование.
  • Трейлеры — Issue: #282, User-Visible: yes на коммите реализации; оба changelog (RU+EN) правлены в этом же коммите с содержательным описанием пользовательского эффекта (без обещания нового UI).

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

  • Полный pytest tests_backend на Linux/CI — недоступен в этой среде (homeassistant не установлен, только pure-subset потенциально прогоняем; чтение кода вместо исполнения для backend-веток test_ha_import_export.py, отмечено как «проверено чтением» в переписке с фоновыми агентами, не исполнением). Это канонически пре-релизный гейт (PROCESS.md §8), но AC9/AC10 частично опираются именно на HA-harness тесты.
  • performance_smoke (полный) — пре-релизный гейт, не гейт ревью; AC14 проверен целевым бенчмарком (см. §5), это достаточно для код-ревью объёма.
  • 7 «слабая связь» смоков (smoke_align_guides, smoke_card_tool_conflict, smoke_editor_gestures, smoke_esc_dialogs, smoke_island_rooms, smoke_optional_space_model, smoke_pan_any_zoom — связь только по _path) — не прогонялись: после того как 9 из 34 более сильно связанных смоков оказались красными, находка уже достаточна, чтобы вернуть задачу автору; дальнейшая трассировка именно этих семи не добавила бы новой информации для вердикта. Автору стоит прогнать их вместе с полным набором перед повторной сдачей.
  • Golden — полный набор (~140 сценариев) — прогнан полностью (не частично), результат в §2/§3.M1; это уже полный прогон, не выборка.
  • npm run docs:accept/скриншоты — не пересъёмка, check-docs.mjs зелёный, этого достаточно; коммит 5adfdb5d уже содержит обновлённые PNG после ребейза, это не предмет этого ревью.
  • Корневая причина H1/H2 не диагностирована до конкретной строки — воспроиз­ ведение и локализация («какой смок, какая проверка, зелёный на dev/красный на HEAD») предоставлены; дальнейшая трассировка — работа автора, не ревьюера (ревьюер не правит продуктовый код).

7. Резюме

Красный вердикт: две категории High-находок (H1 — байпас guard #278 на деградированном пространстве; H2 — детерминированная регрессия в восьми именованных структурных smoke-тестах: Resize+Undo на реальном плане, wall-chain resume, unified-wall split lineage, wall-face overlap, edit-walk fuzz на first-floor фикстуре, editor-tabs cross-limit, plan-snap-overlay, partition openings), плюс четыре находки Medium в скоупе задачи (устаревший golden baseline для AC15, недокументированно узкий AC5-guard, необрабатываемый wall_model_client_outdated для реалистичного старого клиента, ослабленный без обновления фикстуры регрессионный тест #248).

Дешёвые гейты (typecheck/test/build/check-docs/invariants/ benchmark) зелёные — модель миграции, backend-валидация, i18n и документация сами по себе выполнены методологически аккуратно. Но именно те проверки, которые PROCESS.md §8 требует «по необходимости, определяемой diff'ом» (golden, целевые browser-smoke), обнаружили, что барьер commitWallSegmentModel, подключённый в существующие структурные пути редактирования, ломает их — включая явный safety-guard issue #278 и Undo-байт-точность. Задача возвращается в «В разработке»; после исправления H1/H2 нужен новый заход код-ревью (не только повторная проверка находок — весь набор smoke, зафиксированный здесь как красный, должен стать зелёным, и полный regression floor AC12 нужно перепроверить, поскольку правка H1/H2, скорее всего, коснётся самого барьера).