Files
houseplan-card/docs/reviews/CODE-REVIEW-282-r3.md
T
2026-08-25 23:49:05 +00:00

31 KiB
Raw Blame History

CODE-REVIEW-282-r3

  • Issue: #282 — стабильная идентичность сегментов стен (ADR Stage 1)
  • Ветка: issue/282-wall-geometry-model, HEAD 95fa23ca79db830b201e82d9a34a05dcc3cecb29 (после ребейза на origin/dev 5f0a7f5 — по словам автора конфликтов не было; проверено: git merge-base --is-ancestor origin/dev HEAD — true, ветка целиком впереди dev)
  • ТЗ: docs/specs/282-stable-wall-segment-identity.md (spec-review зелёный на r2, 2f30c481; в этом раунде правка только строки статуса — см. §1)
  • Этап: код-ревью (PROCESS.md §2.7)
  • Заход: r3 · блокирующих циклов израсходовано 2 из 4 до этого вердикта
  • Предыдущий раунд: docs/reviews/CODE-REVIEW-282-r2.md, красный, HEAD на тот момент 3588284f (после итогового ребейза переименован без конфликта в 0f54daf3 — коммит git show 0f54daf3 содержит буквально текст «preserve passage validation during wall migration», совпадающий с описанием фикса в исходном r2-документе; сам документ r2 закоммичен в c75c77d5)
  • Вердикт: красный

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

Раунд не расширяется до полного повторного разбора всей подсистемы: финальный ребейз на dev 5f0a7f5 прошёл без конфликта (в отличие от двух предыдущих раундов, где конфликт вносил новый код прямо в identity-барьер и поэтому требовал полного разбора). Дельта r2→r3 (0f54daf3..HEAD, что то же самое, что c75c77d5..HEAD за вычетом самого коммита с документом r2) — 5 коммитов, 17 файлов без учёта review-документа:

demo/smoke_near_axis_optimize.mjs, demo/smoke_openwall.mjs,
demo/smoke_optimize_coincident_partition.mjs,
demo/smoke_resize_outer_reconciliation.mjs,
demo/smoke_resize_virtual_thick.mjs, demo/smoke_sun.mjs,
demo/smoke_wall_thickness.mjs (новый),
custom_components/houseplan/frontend/houseplan-card.js, dist/houseplan-card.js,
docs/CHANGELOG.md, docs/CHANGELOG.ru.md, docs/images/screenshots.json,
docs/specs/282-stable-wall-segment-identity.md,
src/houseplan-card.ts, src/space-render.ts,
test/open-passage-contract.test.mjs, test/wall-segment-model.test.mjs

Разобрана полностью именно эта дельта: обе находки r2 (H1, H2) закрыты в ней же (коммит 20f5c396 = переименованный bcf8089), и следом идёт самостоятельный коммит cfc1bb06 («fix: preserve migrated wall interactions») с продуктовым кодом в src/houseplan-card.ts, не связанным ни с одной находкой r1/r2 — по методологии §2.10 такой код в дельте разбирается так же строго, как если бы он пришёл первым разом.

Остальной ~60-файловый диапазон диффа #282 (миграция, backend-зеркало, import/export, i18n, документация подсистемы, lineage-логика wall-segment-model.ts, mutation-gate) дельтой не затронут — наследуется из r2 (§6 ниже), она сама наследовала это из r1.

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

Гейт Команда Результат
Typecheck npx tsc --noEmit зелёный
Build + sync бандла npm run build && npm run bundle:sync зелёный; дерево осталось чистым (git status пусто) — предъявленный бандл побайтово совпадает с пересобранным из исходников на HEAD
Unit/frontend npm test 1335 тестов, 1334 passed, 0 failed, 1 skipped — H1(r2) закрыт, регрессий нет
Docs fingerprint node scripts/check-docs.mjs зелёный (7 файлов, 10 внешних ссылок) — обязателен, дельта трогает src/**
Model invariants (синтетика) часть npm test зелёный
Model invariants (реальная нагрузка) node scripts/model-invariants.mjs --config <large-house dump, v7 и после commitWallSegmentModel> зелёный до и после миграции: «ссылки разрешимы, записи толщины находятся»
Model invariants — решётка node scripts/model-invariants.mjs --config <large-house v8> --lattice 0.00% шума у узла на всех классах объектов (room/partition/wall/column/opening/decor)
Smoke selection (delta-scoped) node scripts/smoke-select.mjs --base c75c77d5 --head HEAD 7 прямых совпадений, 0 зарегистрированных, 0 слабых — см. §4 разбор по каждой строке
Целевые смоки дельты node demo/smoke_{junction_holes,active_chain_ink,drag_bounds,grid_snap,infinite_canvas,sun,wall_union_isolation}.mjs все 7 — OK
Смоки, изменённые самой дельтой node demo/smoke_{openwall,resize_virtual_thick,near_axis_optimize,optimize_coincident_partition,resize_outer_reconciliation,wall_thickness}.mjs все 6 — OK
AC7-евиденс смок (не в дельте, но прямо касается найденного) node demo/smoke_open_passage.mjs формально OK (все 13 проверок), но см. H1 — сама проверка staticCutsAndFillsPassage не способна обнаружить найденный дефект (проверяет наличие элементов, не форму пути)
Целевой диагностический прогон (не входит в проектный набор) см. H1: точечный запуск wallBodiesUnionPath с продукционными константами + модифицированная локальная копия smoke_open_passage.mjs (не коммитилась, лежит вне репозитория) воспроизводит дефект детерминированно, дважды
CI на точном SHA gh run view 32910611706 + gh run view 32911155216 (Matysh/houseplan-card) первый прогон: frontend/hacs/hassfest/backend/performance_smoke/golden/smoke(1-3) зелёные, process-gate красный (инфраструктурная причина — force-push обнулил before); второй прогон: process-gate зелёный, тяжёлые джобы легитимно reused (побайтово те же входы, механизм #208)
Mutation gate не перепрогонял отдельно — дельта не трогает ни один из трёх поименованных wall-identity-*-мутантов (файлы plan-optimizer.ts/history-restore/editor-commit не в дельте); автор заявляет 226/226 на этом же HEAD принято из хендоффа автора, не переисполнено мной
pytest tests_backend не прогонялся — среда без homeassistant (как в r2 §8) backend в дельте не тронут вовсе (все 17 файлов — frontend/demo/docs) — не требуется в этом раунде
Полный performance_smoke, golden:verify локально не прогонялся отдельно пре-релизный гейт; CI-прогон (см. выше) покрывает golden/performance на точном SHA

Дешёвые гейты и все целевые смоки дельты — зелёные. Но целевой AC7-evidence смок, при точечной проверке глубже штатной ассерции, обнаруживает находку H1.

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

H1. AC7 всё ещё нарушен: Static-карточка рисует сплошную стену прямо по месту прохода после миграции в v8

Файл: src/space-render.ts:437 (renderSpaceStatic, канонический геометрический union стен для Static-карточки) — строка не входит в дельту r2→r3 (не менялась ни в 20f5c396, ни в любом более раннем коммите #282), но её поведение изменилось из-за самой Stage 1: до миграции проход без хозяина не имел opening.host вовсе; после миграции в v8 любой не-partition opening получает host: {kind:'wall', id, t} (hostRoomOpenings, src/wall-segment-model.ts:531-557, не тронут дельтой, но это и есть источник новых входных данных для строки 437).

// src/space-render.ts:434-443 (без изменений в этой дельте)
const canonicalWallGeometry = needsCanonicalWallGeometry
  ? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
    space.rooms, walls, [], [
      ...staticPassages.filter((opening) => !opening.host).map((opening) => ({
        x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
      })),
      ...hostedCompositeOpenings,
    ], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
  ))
  : null;

Ровно двумя строками выше, в этой же дельте, H2(r2) закрыт правильным паттерном: opening.host?.kind !== 'partition' / !opening.host || opening.host.kind === 'wall' (строки 221, 279). Строка 437 использует старый одноусловный !opening.host — единственное оставшееся место в файле с этим паттерном (проверено grep -n "\.host\b" src/*.ts: везде, где встречается opening.host/o.host вне space-render.ts:437, различение kind === 'wall' уже сделано — plan-geometry-preflight.ts:207,259, houseplan-card.ts:9204, и сам space-render.ts:221,279).

Последствие: после того как пространство мигрирует в v8 (любое структурное редактирование, Optimize, либо просто открытие Plan-редактора), обычный (не-partition) passage получает host.kind === 'wall' и выпадает из списка вырезов, который wallBodiesUnionPath вычитает из сплошной кладки — масонри рисуется сплошным прямо по месту прохода. Декоративная заливка тоннеля (passageDataTunnels, не затронута багом) продолжает рисоваться, но раньше в DOM-порядке (space-render.ts:540/544 против 545 для .wallbodies) — сплошная стена рисуется поверх неё. Проход, который ТЗ называет «negative architecture» (комментарий в самом demo/smoke_open_passage.mjs:1-3), визуально становится обычной глухой стеной на Static-карточке.

showBorders (стены вообще рисуются на Static) по умолчанию включён именно для пространств без залитого plan-изображения (src/logic.ts:1265, showBorders: s.show_borders ?? noPlan — «spaces without a plan default to visible markup») — то есть для основного онбординг-сценария J4 (docs/SCOPE.md: «от нуля до плана за вечер... draw»), не для редкого угла.

Доказательство (исполнено, не только прочитано):

  1. Точечный прогон чистой функции с продукционными константами проекта (GRID_STEP_N=1/240, GRID_PITCH=1000/240, NORM_W=1000), скомпилированной тем же tsc -p tsconfig.test.json, что используют штатные unit-тесты:

    wallBodiesUnionPath(rooms, walls, [], [passage], ...).d
      → "M 491.67 100 L 508.33 100 L 508.33 455 L 491.67 455 Z
          M 491.67 545 L 508.33 545 L 508.33 900 L 491.67 900 Z"   (разрыв есть)
    wallBodiesUnionPath(rooms, walls, [], [], ...).d
      → "M 491.67 100 L 508.33 100 L 508.33 900 L 491.67 900 Z"   (сплошная стена)
    

    Присутствие/отсутствие объекта прохода в 4-м аргументе детерминированно решает, будет ли в стене разрыв — то есть исключение host.kind==='wall' этим фильтром гарантированно убирает разрыв.

  2. Полный браузерный прогон: локальная копия (вне репозитория, не коммитилась и не будет) demo/smoke_open_passage.mjs с одной добавленной диагностикой — чтением d у реального <path> внутри .wallbodies на том же самом fixture, что и штатный смок (проход passage-main, x=0.5,y=0.45,length=0.1, стена cm:20, после реального _saveOpening() → _commitPhysicalGeometry → v8), собранная тем же npm run build && npm run bundle:sync, что и остальные гейты этого раунда:

    "wallbodyPathCount": 2,
    "wallbodyD": "M 491.67 200 L 508.33 200 L 508.33 700 L 491.67 700 Z
                   || M 491.67 200 L 508.33 200 L 508.33 700 L 491.67 700 Z",
    "wallVerticesNearPassage": []
    

    Оба пути — сплошной прямоугольник от y=200 до y=700, разрыва у y=450 (центр прохода) нет. Воспроизведено дважды, детерминированно.

  3. Почему штатный smoke_open_passage.mjs (тот, что числится evidence AC7 в самом ТЗ, §-«Доказательство: source guard + projection snapshots + existing geometry suite») это пропускает: его единственная проверка формы — staticCutsAndFillsPassage = !!staticRoot.querySelector('.wallbodies') && !!staticRoot.querySelector('.static-opening-tunnels [data-kind="passage"]') — оба селектора существуют независимо от того, вырезана ли стена: группа .wallbodies рендерится, если есть стены вообще, а тоннель считается по отдельному, не затронутому багом пути (passageTunnelGeometry, строки 444-452, использует нефильтрованный staticPassages). Проверка смотрит на присутствие DOM-узлов, а не на геометрию пути — она не может упасть на этом классе дефекта ни сейчас, ни раньше.

Дополнительно: docs/CHANGELOG.md/.ru.md, добавленные тем же коммитом 20f5c396, который закрывал H2(r2), формулируют это как решённое: «The compact space card keeps wall-hosted doors, windows, gates and passages visible after the migration as well» / «...двери, окна, ворота и проходы, привязанные к стенам». Для дверей/окон/ворот это верно (они не проходят через wallBodiesUnionPath вовсе — Static рисует их старым символьным путём). Для проходов утверждение неточное: символ и тоннель-заливка видимы, но физический вырез в кладке — нет.

Итог: прямое нарушение AC7 («projection даёт текущим full, static... consumers прежние inputs»; сам ТЗ называет доказательством именно «existing geometry suite», и это ровно тот тест, который проверка выше не способна исполнить содержательно) на документированной поверхности продукта (houseplan-space-card, ARCHITECTURE.md «Second card», с v1.16.0), конфликт с J1/J2 docs/SCOPE.md (план должен показывать, что происходит и где — «negative architecture» превращается в глухую стену), тот же класс дефекта, что и H2(r2), только один код-путь глубже в той же функции. Блокирует.

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

Новых находок Medium нет. Мелкое наблюдение без действия: smoke_near_axis_optimize.mjs ослабил проверку точного соответствия индексов вершин (north.poly[1] === south.poly[0]) до .some(point => samePoint(...)) — без сопроводительного комментария, чем вызвано изменение порядка точек. Не поднимаю до находки: сама геометрическая позиция всё ещё проверяется байт-точно, теряется только проверка порядка индексов, который не является частью ни одного AC.

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

Находка r2 Чем закрыта Где это видно
H1 — npm test красный: validate_opening_passages( встречается 3 раза в websocket_api.py, тест ожидал 2 test/open-passage-contract.test.mjs:76-82 теперь ожидает 3 и явно матчит оба вызова (до и после барьера v7→v8) по тексту npm test → 1334 passed / 1 skipped / 0 failed (было 1329 passed / 1 failed) — перепрогнано мной
H2 — AC7: houseplan-space-card теряет wall-hosted opening (символ/материализация) после v8 resolvedHosted теперь if (opening.host?.kind !== 'partition') return []; resolvedRawOpenings теперь if (!opening.host || opening.host.kind === 'wall') return [opening] src/space-render.ts:217-282; node demo/smoke_open_passage.mjs → staticDoesNotInventPassageSymbol/staticCutsAndFillsPassage OK — перепрогнано мной. Но соседняя строка 437 этой же функции, отвечающая за физический вырез кладки под проходом, не была приведена к тому же паттерну — см. H1 этого раунда: класс дефекта H2(r2) закрыт не полностью, только для символа/материализации, не для геометрии кладки

Находка закрыта частично: правка правильная и достаточная для того, что она адресовала (материализация/символ), но не покрывает соседний код-путь той же функции, потребляющий тот же новый host.kind === 'wall' — ровно та проверка, которую сам документ r2 в итоговом абзаце (§9) явно рекомендовал: «стоит проверить на этом же файле и другие пути, потребляющие opening.host…».

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

Дельта 0f54daf3..HEAD не касается — принято на основании выводов r2 (docs/reviews/CODE-REVIEW-282-r2.md, HEAD на котором получен вывод — 3588284f/переименован в 0f54daf3), которая сама наследовала это из r1 (5adfdb5d/466a6765):

  • Migration determinism/idempotence, backend-зеркало валидации (unique-id, owner-count, dangling refs, edge-geometry parity, _wall_catalog_projection), import/export remap, i18n-ключи (toast.wall_model_migration_blocked, toast.wall_model_client_outdated, gs.wall_segments_migrated, wall_model.reason.*), документация (WALL-THICKNESS.md, CANVAS.md, USER-GUIDE.{ru,}.md, TESTING.md), config-field-registry.mjs, три wall-identity-* мутанта mutation-gate, lineage hints (fixedTopologyWallLineageHints), off-grid guard база (wallModelOffGridValueCount, до добавления additionalAuthoredPoints в этой дельте) — не в дельте r2→r3 (проверено git diff --stat c75c77d5..HEAD, приведён в §1), наследуется без переразбора.
  • Guard-порядок #278, 8 именованных structural-smoke из H2(r1) — файлы не в дельте r2→r3, наследуются из r2 §5/§6 (которая сама перепроверила их исполнением на своём HEAD).

Обоснование: находка этого раунда (H1) обнаружена не потому, что унаследованный код изменился, а потому, что дельта r2→r3 (миграция host.kind==='wall', действующая с самого Stage 1, то есть с r1) впервые была прослежена до всех потребителей opening.host в файле, который сама дельта модифицировала — это ревизия по следу дельты (§2.10), а не расширение на несвязанный код.

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

  • Sun rays после структурного редактирования (cfc1bb06, src/houseplan-card.ts:15465-15471) — фильтр окон-источников солнца заменён с !o.host на o.host?.kind !== 'partition' по той же причине, что и H2(r2); прочитано и подтверждено прогоном: node demo/smoke_sun.mjs → все проверки OK, включая переписанные aboveThresholdDrawn/ backAboveThreshold (теперь защищены от null через layer()?.).
  • additionalAuthoredPoints в _commitPhysicalGeometry (src/houseplan-card.ts:7296-7367) — открытая пользователем граница добавляет свои же конечные точки в список «авторских» точек до сравнения off-grid-счётчика, так что открытие границы на уже мигрированном плане не выглядит как рост шума и не блокируется собственным guard-ом; прочитано, подтверждено node demo/smoke_openwall.mjs → OK (все проверки), включая переработанный сценарий второго открытого пролёта через инструмент boundary, а не через прямую инъекцию open_to.
  • Явный ноль толщины не воскресает (src/houseplan-card.ts:12002-12010, зеркалирование next→wall_segments[].cm до _normalizeWalls) — новый unit-тест test/wall-segment-model.test.mjs («an explicit canonical zero is not resurrected…») зелёный в составе npm test; логика прочитана и соответствует комментарию в коде.
  • Обновлённые смоки редактирования (smoke_resize_virtual_thick, smoke_near_axis_optimize, smoke_optimize_coincident_partition, smoke_resize_outer_reconciliation, smoke_wall_thickness) — изменения адаптируют ассерции к тому, что после Optimize/Resize пространство законно становится v8 и все openings несут host.kind==='wall'; новый предикат wallHostResolves проверяет не только присутствие host, но и то, что он реально резолвится к существующему wall_segments[] — это усиление проверки, не ослабление; все 6 файлов перепрогнаны мной, зелёные.
  • Model invariants на реальной нагрузке (large-house, 3486 координат) — 0% шума у узла до и после commitWallSegmentModel, ссылки разрешимы — перепрогнано мной с нуля (собственный дамп фикстуры), не только принято из хендоффа автора.
  • Трейлеры — все 5 коммитов дельты (20f5c396, cfc1bb06, ad54716d, 063026bc, 95fa23ca) несут Issue: #282; два User-Visible: yes (20f5c396, cfc1bb06) правят оба changelog в том же коммите (проверено git show --stat на каждом).
  • CI на точном SHA — смоки (3 шарда), golden, performance, backend, frontend, hacs, hassfest зелёные (первый прогон, за вычетом инфраструктурного сбоя process-gate от force-push); повторный прогон закрыл process-gate и легитимно переиспользовал тяжёлые джобы (побайтово идентичные входы).

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

  • pytest tests_backend — не запускался: backend не входит в дельту r2→r3 вовсе (все 17 изменённых файлов — frontend/demo/docs), не требуется в этом раунде; полный HA-харнесс остаётся Linux CI-гейтом.
  • Mutation gate, полный performance_smoke, golden:verify локально — не перепрогонялись мной отдельно: ни один из трёх поимённых wall-identity-* мутантов не задет дельтой (файлы вне диффа), а golden/performance покрыты зелёным CI-прогоном на точном SHA (§2) — считаю это достаточным для этих трёх пунктов в данном раунде.
  • 7 «слабая связь» смоков (smoke_align_guides и другие, связь только по _path) из полного origin/dev..HEAD смок-подбора — не прогонялись: методология не изменилась с r2, найденная блокирующая находка (H1) уже достаточна для возврата, а сама находка обнаружена не через этот список, а через целевой разбор дельты.
  • Глубокий аудит остальных потребителей .host за пределами space-render.ts (hidden-isometric, iso-openings.ts) — точечно проверено (grep не находит там обращений к .host вовсе — изометрия потребляет уже резолвленные _openingsR из houseplan-card.ts, где паттерн подтверждённо корректен ещё с r2), но не воспроизведено исполнением отдельным браузерным прогоном — риск ниже, чем у Static, так как код там не содержит собственного !opening.host-фильтра.

9. Резюме

Красный вердикт, третий подряд, но предметно иной находке, чем r1/r2: обе находки r2 (H1, H2) закрыты — H1 полностью и подтверждено исполнением (npm test зелёный), H2 закрыт для символа/материализации, но не для физического выреза кладки под проходом в той же функции того же файла. Именно этот непокрытый код-путь — src/space-render.ts:437 — воспроизведён исполнением (не только прочитан) как реальный дефект: Static-карточка рисует сплошную стену прямо по месту прохода на любом пространстве, мигрировавшем в v8, если проход не привязан к перегородке. Это прямое нарушение AC7 задачи на документированной, реально используемой поверхности продукта, в сценарии, где стены по умолчанию видимы (J4, безкартиночный план).

Задача возвращается в «В разработке». Фикс структурно локален (тот же паттерн opening.host?.kind !== 'partition' уже применён двумя строками выше в этом же файле) и не должен требовать полного повторного разбора подсистемы в r4, если дельта останется ограничена этой строкой и её прямыми следствиями — но стоит одновременно перепроверить, не остался ли где-то в проекте ещё один потребитель opening.host с тем же устаревшим одноусловным !opening.host (в этом раунде такой список исчерпан grep, но grep не ловит эквивалентную логику, выраженную иначе).