28 KiB
CODE-REVIEW-282-r1
- Issue: #282 — стабильная идентичность сегментов стен (ADR Stage 1)
- Ветка:
issue/282-wall-geometry-model, HEAD5adfdb5d(после ребейза наorigin/devf2460dc8) - ТЗ:
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_segmentsparity, 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зарегистрированы какcurrentv8, с описанной миграцией/совместимостью.- 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, скорее всего, коснётся самого барьера).