# CODE-REVIEW-288-r1 - **Issue:** https://github.com/Matysh/houseplan-card/issues/288 - **Этап:** code (PROCESS.md §2.7) - **Заход:** r1 · блокирующих циклов израсходовано 0 из 4 - **Ветка:** `issue/288-bounded-multiwall-corridor` - **SHA на момент ревью:** `4aa79dee6b1e66946c9fb97ee38fc855b0020808` - **Диапазон:** `origin/dev...HEAD` (2 продуктовых/тестовых коммита: `cb0f9e73` спецификация, `fba615e9` доработка ТЗ, `e61315cb`/`3f4b92f5` документы ревью ТЗ — фон; код-ревью касается `ff5e393c` fix + `4aa79dee` docs screenshots) Это первый заход код-ревью для #288 — предыдущие два раунда были ревью ТЗ (SPEC-REVIEW-288-r1 жёлтый → r2 зелёный). Разбор ниже полный, разделы «Закрытие r»/«Унаследовано из r» не нужны (PROCESS.md §2.9 касается только повторных заходов ОДНОГО и того же этапа). ## Скоуп диффа `git diff origin/dev...HEAD --stat`: `src/wall-thickness.ts` (+134/-6), `test/wall-thickness.test.mjs` (+51), `scripts/mutation-gate.mjs` (+14), `demo/smoke_real_plan_masonry.mjs` (числа `PLANS`/`KNOWN_GAP_*`), `docs/{WALL-THICKNESS,ARCHITECTURE,TESTING,CHANGELOG,CHANGELOG.ru}.md`, 10 PNG в `docs/images/**` + `screenshots.json`, `dist/houseplan-card.js` и `custom_components/houseplan/frontend/houseplan-card.js` (сгенерированные, класс D). Ядро изменения: в `buildMultiWallNodeMap` каждый `MultiWallNodeRay` получил поле `continuations` — конечные общие полосы («shared»-стены), примыкающие к дальнему концу одного из `supports` луча. В `bevelMultiWallBody` эти полосы восстанавливаются (`multiWallContinuationStripGeometry`) внутри маски узла, после чего результат `union`-ится вместо старой тройки `outside/preservedExterior/localInside`. ## Как проверялось ### Прочитанное (владельческий/процессный контекст) `docs/SCOPE.md` (J1/J6 — план должен правдиво показывать стены), `AGENTS.md`, `PROCESS.md` §2.7/§4/§7/§8/§12, тело issue #288 и все 5 комментариев (аналитика, «ТЗ готово», два вердикта ревью ТЗ), `docs/specs/288-bounded-multiwall-corridor.md` целиком, `docs/WALL-THICKNESS.md`/`docs/ARCHITECTURE.md`/`docs/TESTING.md` (до и после диффа). ### Гейты — выполнено | Гейт | Команда | Результат | |---|---|---| | Typecheck | `npx tsc --noEmit` | чисто, без ошибок | | Unit | `npm test` | `1221 tests, 1220 pass, 1 skipped, 0 fail` | | Build + bundle parity | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны, `git status` после сборки чист (детерминированный бандл) | | Docs fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` | | Выбор смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 3 «зарегистрированные связи» на `MultiWallNodeRaySupport`: `smoke_junction_patch_resilience`, `smoke_multiwall_junction`, `smoke_multiwall_strip_containment`; порог «широкого» символа (36) не превышен → не считается диффом, задевающим всё | | Целевой смок AC1/AC2/AC5 | `node demo/smoke_real_plan_masonry.mjs` (после `npm run bundle:sync`) | `second-floor: gapCount 0, totalGapSteps 0` (45757 samples); `first-floor: gapCount 0, totalGapSteps 0` (8048 samples); все 8 проверок `true`, итог `OK` | | 3 «зарегистрированные связи» | `node demo/smoke_junction_patch_resilience.mjs`, `node demo/smoke_multiwall_junction.mjs`, `node demo/smoke_multiwall_strip_containment.mjs` | все три — `OK`, все булевы поля `true` (включая #249 discarded wedge, #261 retained wedge, #271 finite rays, #275 protected orthogonal strips — прямое покрытие AC4) | | Инварианты модели | `npm run invariants -- --config ` и то же для first-floor (space взят из `test/fixtures/real-plan-*-floor.json`) | second-floor: `Инварианты выполнены`, без наблюдений; first-floor: `Инварианты выполнены`, 2 наблюдения «exact endpoints вместо своего ключа» (не нарушения; см. ниже) | | Мутант AC7 | `node scripts/mutation-gate.mjs --id=multi-wall-shared-continuation-protection-disabled` | чистый прогон зелёный, мутант красный (`тест покраснел, как обязан`), `поймано 1 из 1` | ### Гейты — не выполнено, с причиной - **`npm run golden:verify` (полная матрица).** `demo/golden/policy.mjs` жёстко запрещает частичный прогон (`--scenario=` кидает `golden verify must run the complete matrix; use capture for a diagnostic --scenario run`) — то есть у этого гейта нет «облегчённой» формы, только весь матрикс. Это ровно тот «полный набор», который PROCESS.md §8 называет предрелизным гейтом, а не гейтом ревью. AC6 сам делает семантический `gapCount === 0` (доказан выше живым прогоном smoke) первичным доказательством, а pixel-baseline — вторичным, принимаемым только из Linux CI. Локально не прогонял; риск регрессии старых `wall-junctions-*`/`multiwall-junction-bevel-*` golden-сцен закрыт иначе: все три «зарегистрированные связи» смоков (см. выше) браузерно проверяют именно эти контракты (#249/#261/#271/#275) через реальный SVG fill API и дают `OK`. - **`python -m pytest tests_backend -q`.** Дифф не трогает `custom_components/**/*.py` — не применимо. - **`npm run performance`/`benchmark_*`.** Не назван в AC8 (только typecheck/test/build/check-docs/targeted smoke/mutation); spec §6 явно относит performance-профиль к предрелизному гейту («перед beta»), не к этому ревью. ### Прочитано, не исполнено - Полная бинарная эквивалентность трёх копий бандла подтверждена `cmp` и `npm run bundle:sync`; байтовое содержимое самого JS не читал построчно — достаточно детерминированной сборки без diff после `npm run build`. - Логика `nearbyBuckets`/дедупликации `continuationKeys`/самоисключения через `candidate.other == node.point` в `buildMultiWallNodeMap` (`src/wall-thickness.ts:1704-1743`) прочитана построчно и прослежена вручную на unit-фикстуре AC3 — корректна. - Архитектурный вопрос «не съедает ли маска ДРУГОГО узла B то, что узел A только восстановил через `continuations`, если их радиусы перекрываются» (тот самый класс риска, для которого уже существует `protectedStrips`, см. комментарий в коде на `src/wall-thickness.ts:2393-2395`) — не доказательство чтением, а **выполнено экспериментально**: собраны два синтетических сценария через `wallBodiesGeometry` (один — независимый удалённый узел с искусственно гигантским радиусом 833, другой — реалистичная цепочка «A—тонкая стена—B», где стена короткая для ОБОИХ узлов и оба регистрируют continuation друг на друга). В обоих случаях итоговая геометрия не теряет ни одной точки (`coverage=1.000` по всей длине связывающей стены). Причина: реальное удаление материала выполняют только `outerCuts`/`retainedCuts` (направленные вдоль конкретных лучей узла), а не абстрактный квадрат маски; всё остальное внутри маски восстанавливает `preservedExterior` (`difference(intersection(boundedCurrent, mask), centre)`), которая не зависит от того, чьему узлу принадлежит стена. Регрессии не найдено, риск не подтвердился — фиксирую как проверенный и закрытый, а не как находку. Скрипты одноразовые, в репозиторий не коммитились (правило «не создавать файлов в рабочей копии»), `git status` после проверки чист. ## Находки ### Medium (в скоупе, чинится в этом же ТЗ) — нарушен собственный порядок релиза из §10/issue Issue #288 (тело, раздел «При слиянии») и `docs/specs/288-bounded-multiwall-corridor.md` §10 требуют одно и то же прямым текстом: ветка `issue/260-fixture-wall-keys` должна попасть в `dev` **до** финальной пересъёмки docs screenshots для #288 — «Порядок обязателен — съёмка до слияния обесценивается слиянием». Проверено по SHA: ``` git merge-base --is-ancestor 9031ba0d origin/dev # НЕ ancestor git branch -a --contains 9031ba0d # только remotes/origin/issue/260-fixture-wall-keys git show origin/dev:demo/fixtures/wall-key.mjs # fatal: path does not exist ``` `9031ba0d` («test(fixtures): write the real wall key, in one place», `Issue: #260`) — последний коммит ветки #260 — не является предком `origin/dev` и нигде в `dev` не присутствует (файл `demo/fixtures/wall-key.mjs` там просто отсутствует). При этом коммит `4aa79dee` («docs: accept screenshots after junction repair», автор Sergey Matyunin, `Issue: #288`) уже переснял и принял 10 PNG в `docs/images/**` на текущей ветке #288 — то есть пересъёмка состоялась раньше, чем требовал сам issue. **Смягчающее обстоятельство**, которое понижает это до Medium, а не High: `#260` меняет только `demo/fixtures/**` и тестовые фикстуры (`large-house`, `visual-matrix` для golden/perf), не `src/**`. Отпечаток `check-docs.mjs` считается по `src/**`, поэтому механически job `docs` не покраснеет и пересъёмка не станет технически «протухшей» само по себе. Риск — не в поломке, а в том, что мандатный порядок релиза, который сам issue объявил обязательным именно для экономии повторного прогона «Docs screenshots», не был соблюдён, и решение об этом принял продуктовый автор (коммит от Sergey Matyunin), а не тот, кому issue явно отвёл слияние #260 («не обходится ручным слиянием автора продуктовой задачи» — про слияние самой ветки #260, но дух того же правила: порядок операций реализует не автор #288 в одностороннем порядке). Правка: либо (а) дождаться слияния `issue/260-fixture-wall-keys` в `dev`, затем повторно прогнать `Docs screenshots` и принять командой `npm run docs:accept -- --reviewed --from=<артефакт>` на дереве, которое уже включает #260 (даже если PNG не изменятся байтово — процессная гарантия, которую установил сам issue), либо явно зафиксировать в issue отказ от этого пункта с обоснованием владельца (если порядок уже неактуален). Само по себе это не блокирует корректность правки `wall-thickness.ts`. ### Low — AC3 не проверяет геометрию тела, только метаданные `continuations` `test/wall-thickness.test.mjs`, тест «issue #288 keeps a shared wall attached beyond a short node ray finite» (добавлен этим диффом) строит узел через `buildMultiWallNodeMap` и проверяет только структуру `ray.continuations` (`start`/`u`/`length`/`halfDepth`), но не строит `wallBodiesGeometry` и не измеряет фактическую утрату площади соседней стены — то, что буквально требует AC3 («longitudinal material loss соседней тонкой стены не превышает её собственного half-depth»). Сравните с более старым тестом «issue #271 keeps finite co-directional ray supports…» в том же файле (строки 1199–1240), который для той же категории дефекта дополнительно гоняет `wallBodiesGeometry` и `assertProbeInside/Outside` — паттерн уже есть в файле, здесь не использован. Не блокирует: фактическое поведение на уровне тела подтверждено (а) реальным `demo/smoke_real_plan_masonry.mjs` на двух живых планах (AC1/AC2, строгий `gapCount: 0`), (б) мутантом AC7, который убивает именно этот код (хотя и через тот же метаданными-ограниченный юнит), (в) моей собственной ручной проверкой через `wallBodiesGeometry` на синтетических конфигурациях (см. «Прочитано, не исполнено» выше) — везде `coverage=1.000`, потерь площади не обнаружено. Правка опциональна: добавить в AC3-тест такую же геометрическую проверку, как в #271-тесте, для более прямого соответствия тексту AC3. Оставляю на решение автора — не блокирует зелёный вердикт при отсутствии High. ## Что проверено и корректно - **AC1/AC2** — доказаны напрямую живым прогоном `smoke_real_plan_masonry.mjs`: оба реальных плана дают `gapCount: 0`/`totalGapSteps: 0`, проверены ВСЕ точки контура комнат (плотная выборка), а не только 4 исторических гэпа. `PLANS`/`KNOWN_GAP_COUNT`/`KNOWN_GAP_STEPS` обновлены на нули в том же коммите `ff5e393c`. - **AC3** — юнит `issue #288 keeps a shared wall attached beyond a short node ray finite` прогоняет три масштаба (`scale: 1, 5, 30`, соответствует `cell_cm: 1/5/30`), прямой и реверсированный порядок rays/endpoints, и отдельно проверяет, что при `kind: 'outer'` на дальнем конце `continuations` пуст (не нарушает контракт #249/exterior corridor). Метаданные (`start`/`u`/`length`/`halfDepth`) точно совпадают с ожидаемыми для топологии `349/120/5` шагов, `30/30/20` см. Геометрический вывод (что это реально ограничивает потерю площади) подтверждён отдельно смоком и моей ручной проверкой (см. Low выше и раздел «прочитано, не исполнено»). - **AC4** — регресс всех семи прежних контрактов (#249/#261/#271/#272/#275/#278/#279) покрыт: `npm test` включает их юниты без сбоев (например, тест `issue 230…`, `#278 anonymized regression…`, `#271 keeps finite co-directional…` — видны в живом прогоне выше), и три браузерных смока с прямым совпадением по `MultiWallNodeRaySupport` (`junction_patch_resilience`, `multiwall_junction`, `multiwall_strip_containment`) зелёные, включая явные поля `excessWedgeIsEmpty` (#249), `nodeRemainsFilled` (#261), `viewStopsAtFiniteRayEndpoint` (#271) и полное покрытие `cell_5_mixed_depth_t`/ `cell_1_thick_crossbar_t` (#275). - **AC5** — тот же `smoke_real_plan_masonry.mjs` меряет через реальный production-бандл (`npm run bundle:sync` перед прогоном) и `isPointInFill` в Plan/View; структурные консюмеры (roomGeom/geom/paperGeom) используют один и тот же canonical `wallBodiesGeometry` — код не создаёт consumer-specific патчей (проверено чтением: `multiWallContinuationStripGeometry` вызывается один раз внутри `bevelMultiWallBody`, до всех downstream потребителей). - **AC7** — мутант `multi-wall-shared-continuation-protection-disabled` зарегистрирован в `scripts/mutation-gate.mjs`, инвертирует ключевую проверку `candidate.kind !== 'shared'` → `=== 'shared'`, и убивается юнитом AC3 (продемонстрировано живым прогоном выше). - **AC8 (локальные гейты)** — все выполнены и зелёные (таблица выше). - **Docs/changelog** — `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md` описывают именно то, что делает код (проверено построчно против диффа `src/wall-thickness.ts`); `docs/CHANGELOG.md`/`CHANGELOG.ru.md` правлены в том же коммите `ff5e393c` с `User-Visible: yes` — трейлеры на обоих коммитах диапазона корректны (`Issue: #288` + `User-Visible: yes/no`). - **Одно число — один источник.** Дифф не добавляет и не меняет ни одной пользовательски видимой числовой величины (это фикс геометрии заливки/ рендера стен, не текстовое отображение размеров); `test/single-source-numbers.test.mjs` прошёл в составе `npm test` без изменений с его стороны — неприменимо по существу, не только механически. - **Совместимость/touch/security/perf (спека §6/§11)** — не подтверждаю отдельным прогоном (не требуется по AC8), но проверено чтением: изменение локально по `rays`/`supports` одного узла, не вводит новый глобальный проход по стенам, не меняет schema/config/layout/Optimize (дифф не трогает соответствующие файлы), не добавляет HA service calls. ## Гейты, которые прогнал/не прогнал — сводка Прогнал: `tsc --noEmit`, `npm test`, `npm run build` + `cmp` бандлов, `node scripts/check-docs.mjs`, `node scripts/smoke-select.mjs`, `demo/smoke_real_plan_masonry.mjs`, три «зарегистрированные связи» смока (`smoke_junction_patch_resilience`, `smoke_multiwall_junction`, `smoke_multiwall_strip_containment`), `npm run invariants` на конфигах, собранных из обеих real-plan фикстур, целевой мутант AC7. Не прогнал: полный `npm run golden:verify` (запрещён частичный прогон инструментом, это предрелизный гейт §8), `pytest tests_backend` (Python не тронут), performance-профили (не названы в AC8). ## Вердикт Жёлтый. Геометрический фикс (`src/wall-thickness.ts`) корректен, полностью покрыт тестами/смоками/мутантом и подтверждён живым прогоном на обоих реальных планах — по существу задача решена и решает заявленный сценарий (J1/J6: непрерывные стены на реальном плане). Единственная причина жёлтого — процессная: пересъёмка docs-скриншотов в этом же диапазоне коммитов состоялась раньше, чем сам issue/спека предписывают слияние `issue/260-fixture-wall-keys` в `dev`. High нет; Medium — один, в скоупе.