Files
houseplan-card/docs/reviews/CODE-REVIEW-282-r2.md
T
2026-08-26 02:21:15 +03:00

30 KiB
Raw Blame History

CODE-REVIEW-282-r2

  • Issue: #282 — стабильная идентичность сегментов стен (ADR Stage 1)
  • Ветка: issue/282-wall-geometry-model, HEAD 3588284f (после ребейза на origin/dev 30698d6e — merge-base подтверждён, ветка полностью впереди dev)
  • ТЗ: docs/specs/282-stable-wall-segment-identity.md (spec-review зелёный на r2, 2f30c481)
  • Этап: код-ревью (PROCESS.md §2.7)
  • Заход: r2 · блокирующих циклов израсходовано 1 из 4 до этого вердикта
  • Предыдущий раунд: docs/reviews/CODE-REVIEW-282-r1.md, красный, HEAD на тот момент 5adfdb5d (SHA назван в документе — не находка). Этот SHA более не существует в истории ветки: после r1 ветка дважды уходила на ребейз (issue-комментарии #282, 19:33 и 21:12/21:21), причём второй ребейз (на dev 30698d6e) потребовал реального разрешения конфликта — по словам автора, «post-rebase fixes also make direct backend Optimize cross the v7→v8 barrier, retain authored active-chain coordinates during conversion, and keep a rejected wall transaction atomic» — то есть новую продуктовую логику, а не только смену родителя.
  • Вердикт: красный

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

Раунд не локален по критериям PROCESS.md §2.10: ребейз на ушедший вперёд dev с конфликтом, разрешённым новым кодом в самом identity-барьере — ровно та подсистема, где r1 нашёл H1/H2. Поэтому разбор полный, но выполнен по факту через дельту 9fc213c6..HEAD (27 файлов, +1793/−1020: коммит 9fc213c6 — это сам документ CODE-REVIEW-282-r1.md, его родитель 466a6765 — переименованный рикбейзом эквивалент 5adfdb5d, на котором получен вердикт r1). Дельта покрывает: src/houseplan-card.ts (_commitPhysicalGeometry, _applyGeometryState), src/wall-segment-model.ts (lineage hints, off-grid guard), src/plan-geometry-preflight.ts, backend validation.py/wall_segment_model.py/websocket_api.py, тесты и mutation-gate. Остальные ~45 файлов исходного 62-файлового диффа r1 (import/export remap, i18n, документация, backend-зеркало валидации помимо перечисленного) дельтой не задеты — раздел «Унаследовано из r1» ниже.

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

Гейт Команда Результат
Typecheck npx tsc --noEmit зелёный
Unit/frontend npm test 1331 тестов, 1329 passed, 1 failed, 1 skipped — см. H1
Build + sync бандла 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 test (test/model-invariants.test.mjs, test/wall-segment-model.test.mjs) зелёный
Model invariants (реальная нагрузка) node scripts/model-invariants.mjs --config <large-house.mjs, до и после commitWallSegmentModel> зелёный до миграции («ссылки разрешимы, записи толщины находятся»), зелёный после; --lattice: 0.00% шума у узла
Smoke selection node scripts/smoke-select.mjs --base origin/dev --head HEAD 31 прямое совпадение, 5 зарегистрированных связей, 7 слабых — вывод см. §4
Golden npm run golden:verify зелёный, 129/129 сценариев, в т.ч. оба сценария из M1(r1)
Mutation gate node scripts/mutation-gate.mjs --check зелёный, 226/226 мутантов пойманы, включая 3 мутанта #282 (editor-commit/history-restore/optimize barrier)
Named/registered smokes 31 прямых + 5 зарегистрированных = 36 запущено 1 красный (smoke_open_passage.mjs) — см. H2, остальные 35 зелёные
Backend pure (tests_backend) не прогонялся мной — среда без homeassistant/voluptuous/.venv-backend (см. §6) проверено чтением диффа validation.py/wall_segment_model.py + нового backend-теста, не исполнением
pytest tests_backend (полный, Linux CI) не прогонялся пре-релизный гейт, вне объёма код-ревью (§8)
Performance-профили (полный performance_smoke) не прогонялся пре-релизный гейт; целевой npm run benchmark:wall-model дельтой не задет (файл вне диффа)

Дешёвые гейты (tsc, build, check-docs) зелёные. Но npm test сам по себе красный — это не пре-релизный, а обязательный на каждом раунде гейт (§8, §2.10), и он не проходит на предъявленном HEAD. Расширенные гейты (invariants, smoke-select, golden, mutation-gate, целевые smoke) прогнаны, потому что дельта меняет геометрию/ссылки на неё и барьер идентичности — именно то сочетание, для которого §8 требует их «по необходимости». Golden и mutation-gate теперь полностью зелёные (закрывают M1(r1) и M2(r1) — см. §5 «Закрытие раунда r1»); но один из 36 запущенных смоков нашёл новую, не связанную с r1 регрессию (H2).

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

H1. npm test красный: диагностика больше не соответствует контракту, который сама же проверяет

Файл: test/open-passage-contract.test.mjs:70-74, тест «all write/import paths invoke the semantic passage validator».

Воспроизведение:

npm test
# ...
not ok 688 - all write/import paths invoke the semantic passage validator
  Expected values to be strictly equal:
  3 !== 2
  location: test/open-passage-contract.test.mjs:71

Тест жёстко ожидает ровно 2 вхождения validate_opening_passages( в custom_components/houseplan/websocket_api.py. Последний коммит дельты, 3588284f («fix: preserve passage validation during wall migration»), осознанно добавил третий вызов — validate_opening_passages(candidate_config, config_data.get("config")) в ws_plan_optimize до барьера commit_wall_segment_model, рядом с уже существующим вызовом после барьера (строки 1655 и 1672; третий, старый вызов на 1327 — в ws_config_set). Это осмысленное и корректное по существу исправление (см. §5, closure M-out comment #16 в issue): без него Optimize возвращал общий wall_model_migration_blocked вместо конкретного invalid_passage_fields-кода на конфиге с некорректным passage ещё до миграции. Но тест, фиксирующий количество мест вызова, не обновлён вместе с фиксом — он писался под контракт «ровно 2 вызова» и не отражает намеренно добавленный третий.

Это прямое нарушение дисциплины: npm test — гейт, обязательный в каждом раунде (§2.10, §8), и он сейчас красный на предъявленном коде. Хендофф- комментарий автора к 3588284f (issue, 22:04:14) называет только узкий прогон 14 pure-passage-кейсов и process-gate, не полный npm test — расхождение между «verified» и фактическим состоянием gate.

Доказательство: тест уже красный на HEAD, механически (node --test test/open-passage-contract.test.mjs); падение детерминировано, не флуктуация.

H2. AC7 нарушен: opening на обычной стене исчезает со Static-карточки после миграции в v8

Файл: demo/smoke_open_passage.mjs — не тронут этой дельтой (0 строк в git diff --stat origin/dev...HEAD). Корень — src/space-render.ts:217-278 (renderSpaceStatic, единственный потребитель — space-card.ts:770, т. е. houseplan-space-card, документированная в ARCHITECTURE.md «Second card»); файл не тронут диффом #282 вовсе (git diff --stat origin/dev...HEAD -- src/space-render.ts → пусто).

Воспроизведение: на origin/dev (30698d6e, отдельный git-worktree, тот же npm run bundle:sync):

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

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

"staticCutsAndFillsPassage": false
FAILED (1)

Целевая диагностика (см. приложенный debug-прогон) показывает точный механизм: после того как _saveOpening() проводит объект через _commitPhysicalGeometry (v7→v8 baseline-replay из c694f6ea), опция passage-main корректно получает host: {kind:'wall', id:'wall-…', t:0.5} — барьер hostRoomOpenings (src/wall-segment-model.ts:530-556) тегирует любой незакреплённый за partition opening (door/window/gate/passage одинаково, без разбора по типу) таким host при миграции. Но renderSpaceStatic (space-render.ts:217-222, resolvedHosted) резолвит opening.host только через resolvePartitionOpeningCompat — функцию, понимающую исключительно host.kind === 'partition'. Для нашего host.kind === 'wall' резолюция не находится, resolvedHosted пуст для этой записи, и resolvedRawOpenings (строки 274-278) на строке return resolved ? [materializePartitionOpening(...)] : [] молча роняет opening из всего дальнейшего рендера Static-карточки — не только из tunnel-заливки прохода, а из любой отрисовки (символ, вырез в стене, hit-box).

Сам код src/houseplan-card.ts (полный/Plan/View card) везде корректно различает host?.kind === 'partition' от прочих случаев (8295, 8325, 8480, 12766, 12804, 12958) — регрессия локализована именно в space-render.ts, единственном месте, которое ADR Stage 1 должен был обновить и не обновил, хотя сам ТЗ прямо обещает это в AC7: «v8 → legacy… projection даёт текущим full, static… consumers прежние inputs» с доказательством «projection snapshots

  • existing geometry suite» — а именно этот existing-geometry смок (smoke_open_passage.mjs, часть suite с #157) и ловит нарушение AC7.

Blast radius не ограничен синтетическим passage из смока: hostRoomOpenings единообразно тегирует все типы openings без partition-host при миграции, значит после Optimize/любой структурной правки, переводящей пространство в v8, любой door/window/gate/passage на обычной (не-partition) стене перестаёт отображаться на houseplan-space-card — символ, вырез, hit-box. Это прямой конфликт с J1/J2 из docs/SCOPE.md (план должен показывать, что происходит и где) на реальной, документированной с v1.16.0 поверхности продукта.

Доказательство: тест умеет падать — зафиксирована пара «зелёный на origin/dev 30698d6e / красный на HEAD 3588284f» на байт-идентичном файле теста, дважды воспроизведено. Причина локализована до конкретных строк (в отличие от недиагностированных H1/H2 из r1) через целевой debug-прогон, приложенный к разбору находки, а не только «зелёный/красный».

Итог по H1+H2: оба блокируют. H1 — процессный, лечится синхронизацией числа в тесте с фактическим количеством вызовов (тривиально, но не сделано). H2 — продуктовый: явное нарушение собственного AC7 задачи, широкий blast radius на документированной поверхности продукта, которую эта дельта не касалась и не тестировала.

4. Medium — по существу задачи

Новых находок Medium в этом раунде нет. Все четыре Medium из r1 закрыты — см. §5 «Закрытие раунда r1». Отдельных находок вне скоупа нет — новый issue не заводился.

5. Закрытие раунда r1

Находка r1 Чем закрыта Где это видно
H1 — guard #278 не отклоняет правку деградированного пространства _commitPhysicalGeometry (c694f6ea, src/houseplan-card.ts:7296-7313) теперь вызывает _checkSpacePhysicalGeometry на liveCandidate до прохождения identity-барьера (legacySafe), а не после — восстановлен исходный порядок guard-а #278 node demo/smoke_wall_union_isolation.mjs → OK, все 11 проверок true (перепроверено мной на HEAD, включая три ранее красные: degradedPhysicalEditRejected, rejectedEditHasNoHistoryOrWrite, rejectedEditHasLocalizedToast)
H2 — 8 именованных structural-smoke красные Переработан _commitPhysicalGeometry: для v7-пространства identity выводится из pre-edit baseline (baselineSource/historyBefore), а не из уже отредактированной геометрии, плюс lineage hints (fixedTopologyWallLineageHints) для сохранения ID при чистом split Все 8 смоков (smoke_resize_pointer_real_plan, smoke_wall_chain_thickness, smoke_unified_wall_tool, smoke_wall_face_overlap, smoke_edit_walk, smoke_editor_tabs, smoke_plan_snap_overlay, smoke_partition_openings) перепрогнаны мной на HEAD — все зелёные
M1 — golden baseline не обновлён под AC15-строку Baseline не потребовался: фикстура optimize-orphan-references-* (demo/golden/matrix.mjs:695-720) уже v8/canonical, Optimize на ней не мигрирует ни одного сегмента, r.wallSegmentsMigrated = 0 → строка gs.wall_segments_migrated не рендерится, визуального различия нет npm run golden:verify → 129/129 passed, включая optimize-orphan-references-dark-en/-light-ru; demo/golden/baselines/** не тронут диффом (подтверждено git log — пусто), что согласуется, а не противоречит
M2 — AST/choke-point guard уже, чем заявлено в AC5/TESTING.md Добавлены два новых именованных мутанта: wall-identity-history-restore-barrier-bypassed (Undo/Redo restore) и wall-identity-optimize-barrier-bypassed (plan-optimizer.ts), плюс переименован исходный в wall-identity-editor-commit-barrier-bypassed — все три известных writer-пути теперь поимённо покрыты node scripts/mutation-gate.mjs --check → 226/226 ok, включая все три wall-identity-* мутанта (перепрогнано мной)
M3 — wall_model_client_outdated не срабатывает на реалистичном echo-сценарии validate_wall_model_transition (custom_components/houseplan/validation.py:185-217) получил новую ветку old_model >= 8 and new_model >= 8 and geometry_changed, сверяющую _wall_catalog_projection (только wall_segments) — расхождение поднимает WallModelClientOutdatedError с текстом «unchanged wall catalogue» Новый тест tests_backend/test_wall_segment_model.py::test_stale_client_echoing_v8_catalog_gets_the_named_error — прочитан, логика соответствует описанному сценарию; не исполнен (см. §6, нет homeassistant/voluptuous в этой среде) — проверено чтением, не исполнением
M4 — регрессионный тест #248 ослаблен без обновления фикстуры test/fixtures/optimize-storage-roundtrip.json обновлена под полный v8-выход, test/plan-optimizer.test.mjs:366 вернул строгий assert.deepEqual(first.config, storageRoundtripFixture.expected.config) вместо частичного сравнения только poly Тест прогнан мной в составе npm test — проходит (часть 1329 passed); дифф test/plan-optimizer.test.mjs (9fc213c6..HEAD) показывает возврат полного deepEqual

Все шесть находок r1 (H1, H2, M1-M4) закрыты предметно, не декларативно. Ни одна из них не осталась открытой или не переоткрылась при моей перепроверке.

6. Унаследовано из r1

Из полного 62-файлового диффа r1 (git diff origin/dev...5adfdb5d — уже недоступен напрямую как SHA, но содержательно эквивалентен диапазону, который описывает docs/reviews/CODE-REVIEW-282-r1.md), эта дельта не касается и поэтому не переразобрано, а принято на основании выводов r1 (документ CODE-REVIEW-282-r1.md, SHA на котором получен вывод — 5adfdb5d/содержательно 466a6765 после ребейза):

  • Migration determinism/idempotence (deterministicWallSegmentId, суффиксы при коллизии) — r1 §5, не задето дельтой (test/wall-segment-model.test.mjs дельта затрагивает только новые тест-кейсы, не эту логику).
  • Backend-зеркало валидации — unique-id, owner-count, dangling refs, edge-geometry parity кроме нового _wall_catalog_projection-блока (см. §5 M3) — r1 §5, файл wall_segment_model.py в дельте затронут (+48/-х строк), но это тот же lineage-код, что и H1/H2-фикс на фронтенде; backend-инварианты (owner-count/dangling refs) не изменены текстуально в дельте.
  • Import/export remap (import_export.py, secrets.token_hex(4)) — не в дельте (git diff --stat 9fc213c6..HEAD не содержит import_export.py), наследуется из r1 §5 без переразбора.
  • i18n-ключи (toast.wall_model_migration_blocked, toast.wall_model_client_outdated, gs.wall_segments_migrated, wall_model.reason.*) — не в дельте, наследуется из r1 §5.
  • Документация (WALL-THICKNESS.md, CANVAS.md, USER-GUIDE.{ru,}.md — кроме TESTING.md, который в дельте, см. §5 M2) — наследуется из r1 §5.
  • config-field-registry.mjs — не в дельте, наследуется.
  • Трейлеры и changelog структуры коммитов r1 — наследуется; трейлеры каждого нового коммита дельты проверены заново мной отдельно (см. §7).

Обоснование, почему это не требует переразбора несмотря на общий полный охват раунда (§1): дельта физически не касается перечисленных файлов (git diff --stat 9fc213c6..HEAD, приведён в §1), а находки этого раунда (H1, H2) обнаружены и локализованы именно в затронутых дельтой файлах (test/open-passage-contract.test.mjs тестирует код websocket_api.py, который в дельте; space-render.ts хоть и не в дельте, но находка H2 — следствие данных, которые генерирует именно delta-код wall-segment-model.ts, т.е. вызвана дельтой, а не унаследованным кодом).

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

  • Трейлеры — все 6 коммитов дельты (c694f6ea, 41d7a63f, c24792b6, 35fce64a, 38b03549, 3588284f) несут Issue: #282; четыре User-Visible: yes (c694f6ea, 35fce64a, 38b03549, 3588284f) — каждый правит оба changelog в том же коммите (проверено git show --stat на каждом).
  • Backend-барьер Optimize (35fce64a) — ws_plan_optimize теперь ведёт тот же v7→v8-барьер, что и frontend _commitPhysicalGeometry (commit_wall_segment_model + too_large-проверка после миграции + WallSegmentMigrationError в списке перехватываемых) — прочитано полностью, логика симметрична фронтенду.
  • Guard-переупорядочивание #278 (H1(r1)) — legacySafe-проверка на liveCandidate до барьера, restore на отказ — прочитано и подтверждено прогоном (см. §5).
  • Lineage hints (fixedTopologyWallLineageHints, adoptWallSegmentModelCandidateInPlace) — прочитаны; логика различения preferredIds/positionalIds через collinearOverlap (защита от «застарелого ординала» при сплите) выглядит осмысленной и подтверждена всем доступным smoke/mutation набором.
  • Off-grid guard с authored path (38b03549) — wallModelOffGridValueCount теперь учитывает _path (активная рисуемая цепочка) как базовую линию роста, не как новое отклонение — юнит-тест на это добавлен и проходит.
  • Три мутанта #282 (editor-commit/history-restore/optimize barrier) — реально пойманы (§2, §5 M2).
  • npm run invariants-эквивалент на реальной нагрузке — прогнан на demo/fixtures/large-house.mjs (3 пространства, 1788 записей по прошлым измерениям Stage 0) до и после commitWallSegmentModel: ссылки разрешимы, записи толщины находятся, 0% шума у узла.
  • 35 из 36 запущенных смоков зелёные (см. §2, §4).

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

  • pytest tests_backend, даже pure-subset — среда исполнения не содержит homeassistant, а custom_components/houseplan/__init__.py безусловно импортирует homeassistant.components.frontend, поэтому даже не-test_ha_*.py модули (test_validation.py, test_wall_segment_model.py) не собираются без полного HA-пакета — это не селективный пропуск conftest.py, а полный отказ коллекции. .venv-backend в этой среде не существует (в отличие от cloud-agent окружения, описанного в AGENTS.md). M3 (§5) и общая корректность validation.py/wall_segment_model.py-диффа проверены чтением, не исполнением — честно назвать это ограничением, не «verified».
  • Полный performance_smoke — пре-релизный гейт (§8); целевой benchmark:wall-model дельтой не затронут (файл вне диффа), поэтому не перепрогонялся отдельно в этом раунде — цифры из r1/хендоффа автора (167-284 мс на p95 при бюджете 500 мс) остаются в силе, так как измеряемый код не в дельте.
  • 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) — не прогонялись: методология идентична r1 (§6 её документа) — уже найденных блокирующих находок достаточно для возврата, и один из 36 более сильно связанных смоков (open_passage) уже красный. Автору стоит прогнать все 7 вместе с полным набором перед следующей сдачей.
  • Глубокий line-by-line аудит buildAtoms/lineage-логики (src/wall-segment-model.ts, весь диапазон 218 изменённых строк) — не выполнен formal-proof, только чтение ключевых веток плюс полное black-box-покрытие (36 смоков, 226 мутантов, 129 golden, полный npm test кроме H1). Смок smoke_open_passage.mjs — прямое доказательство, что black-box-покрытие не исчерпывающее; глубже эту логику не разбирал, так как найденных H1/H2 достаточно, чтобы вернуть задачу автору (тот же принцип, что в r1 §3 «проверять глубже уже не требуется по методологии»).

9. Резюме

Красный вердикт, второй подряд, но по существу другим находкам, чем r1: все шесть находок r1 (2 High + 4 Medium) закрыты предметно и перепроверены исполнением (кроме M3 — чтением, backend-харнесс недоступен). Однако именно дельта, закрывшая r1, внесла две новые блокирующие находки:

  • H1 — npm test красный прямо сейчас: тест на количество вызовов validate_opening_passages( не обновлён под намеренно добавленный третий вызов из последнего коммита дельты. Механически тривиально, но нарушает обязательный на каждом раунде гейт.
  • H2 — нарушение собственного AC7 задачи: houseplan-space-card теряет любой wall-hosted opening (не только passage) после миграции пространства в v8, потому что src/space-render.ts — единственный нетронутый диффом #282 потребитель opening.host — не различает host.kind === 'wall' от host.kind === 'partition' и молча роняет нерезолвленные openings из рендера. Реальный blast radius на документированной поверхности продукта, конфликтующий с J1/J2 docs/SCOPE.md.

Задача возвращается в «В разработке». Оба фикса локальны и не должны требовать повторного полного разбора всей подсистемы в r3, если дельта останется локальной к websocket_api.py-тесту и space-render.ts; но space-render.ts до сих пор не в диффе #282 вовсе — стоит проверить на этом же файле и другие пути, потребляющие opening.host (hidden-isometric, если он существует отдельно), а не только houseplan-space-card.