30 KiB
CODE-REVIEW-282-r2
- Issue: #282 — стабильная идентичность сегментов стен (ADR Stage 1)
- Ветка:
issue/282-wall-geometry-model, HEAD3588284f(после ребейза наorigin/dev30698d6e— 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), причём второй ребейз (наdev30698d6e) потребовал реального разрешения конфликта — по словам автора, «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/J2docs/SCOPE.md.
Задача возвращается в «В разработке». Оба фикса локальны и не должны требовать
повторного полного разбора всей подсистемы в r3, если дельта останется
локальной к websocket_api.py-тесту и space-render.ts; но space-render.ts
до сих пор не в диффе #282 вовсе — стоит проверить на этом же файле и другие
пути, потребляющие opening.host (hidden-isometric, если он существует
отдельно), а не только houseplan-space-card.