31 KiB
CODE-REVIEW-282-r3
- Issue: #282 — стабильная идентичность сегментов стен (ADR Stage 1)
- Ветка:
issue/282-wall-geometry-model, HEAD95fa23ca79db830b201e82d9a34a05dcc3cecb29(после ребейза наorigin/dev5f0a7f5— по словам автора конфликтов не было; проверено: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»), не для редкого угла.
Доказательство (исполнено, не только прочитано):
-
Точечный прогон чистой функции с продукционными константами проекта (
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'этим фильтром гарантированно убирает разрыв. -
Полный браузерный прогон: локальная копия (вне репозитория, не коммитилась и не будет)
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 (центр прохода) нет. Воспроизведено дважды, детерминированно.
-
Почему штатный
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 не ловит эквивалентную
логику, выраженную иначе).