Files
houseplan-card/docs/reviews/CODE-REVIEW-302-r4.md
T
claude[bot] 31d7a70e34
Validate / docs (push) Failing after 28s
Validate / process-workflow-sync (push) Successful in 36s
Validate / provenance (push) Successful in 55s
Validate / process-gate (push) Failing after 43s
Validate / changes (push) Successful in 40s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 44s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 15:12:23 +00:00

255 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-302-r4
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — материал узла стен: переработка на аддитивную логику
- Этап: код-ревью (PROCESS.md §2.7)
- Заход: r4 · блокирующих циклов израсходовано на входе 2/4 (r1 жёлтый, r2 жёлтый, r3 зелёный — бюджет не тратит)
- Ревьюемый SHA: `96318fb94f6d64e96054b8ba4d34f615e3088477`
- Ветка: `issue/302-junction-node-material`
- Вердикт: **зелёный** · High: 0 · Medium: 0
## Почему это ПОЛНЫЙ разбор, а не разбор по дельте
r3 (SHA `560c5b81`, вердикт зелёный) не смержился: конфликт при ребейзе на
`dev` (комментарий владельца от 14:33). Автор перевёл задачу в
`S6-in-progress`, выполнил `git rebase origin/dev`, разрешил конфликт и
запушил заново — старые SHA (`fa1112e7`, `42e02396`, `560c5b81`) при этом
переизданы force-push'ем и физически не существуют в текущей истории
(`git cat-file -t <sha>` → `Not a valid object name`; `git fetch origin <sha>`
→ `couldn't find remote ref`). Разбор по дельте против объекта, которого нет,
невозможен буквально.
Существеннее: сам ребейз — не техническая формальность. `git merge-base HEAD
origin/dev` равен текущему тику `origin/dev` (`1575b1e8`), и в диапазон между
прежней базой ветки и этим тиком входит `a2b4d32b fix(plan): keep wall axes
across tools` (#304) — реальная фича, физически задевающая `src/houseplan-card.ts`
и рендер осевых линий, которая столкнулась с этой веткой ровно в одной паре
golden-сцен (`safe-resize-handles-clamp-*`, где стыкуются осевые линии из #304
и сомкнутая вершина ромба из decision №5 этой задачи). Это тот samый случай,
который правило §7.2 требует разбирать полностью: «после ребейза это другой
код». Разбор ниже поэтому охватывает весь диапазон `origin/dev...HEAD`
(49 файлов, 2257/-356), а не только три коммита ребейза.
Раздел «Унаследовано» — не для сокращения объёма проверки, а для
трассируемости: почти весь контроль ниже я выполнил заново и независимо в
этом раунде (перечень гейтов — раздел «Что проверено» и «Гейты»), а не
переписал из r1–r3 на слово. Пересечение с прежними выводами является
следствием того, что `git diff <SHA r3>..HEAD -- src/ test/ demo/ scripts/`
пуст (см. ниже) — код фичи не менялся с r2, а не потому что я решил доверять.
## Дельта ребейза (три коммита после r3)
| коммит | что делает |
|---|---|
| `b2b26407` | принимает объединённые эталоны `safe-resize-handles-clamp-{dark,light}` (осевые линии #304 + сомкнутая вершина ромба decision №5); `baselines-index.json` починен по фактическим PNG после конфликта слияния |
| `d3927f2e` | rebundle обеих копий (`dist/`, `custom_components/.../houseplan-card.js`) на объединённом дереве |
| `96318fb9` | обновляет `docs/images/screenshots.json` (отпечаток скриншотов документации, обязателен при любой правке `src/**`) |
Подтверждено чтением: `git diff <SHA r3>..HEAD -- src/ test/ demo/ scripts/`
не выполним напрямую (SHA отсутствует), но три коммита ребейза сами по себе
трогают только `demo/golden/baselines/**`, `baselines-index.json`, обе копии
бандла и `docs/images/screenshots.json` — ни строки `src/**`/`test/**`
/`demo/**` (кроме бинарных PNG) эти три коммита не меняют (см. `git show
--stat` на каждом, приведено в комментарии issue тем же перечнем файлов).
Отсюда: узловая геометрия (`junctionNodeGeometry`, `junctionContractHoles`,
`junctionNodeBound`, точки вызова в `wallBodiesGeometry`) идентична r2/r3
байт-в-байт; я перечитал её самостоятельно (раздел ниже), а не принял этот
факт на слово.
## Скоуп проверки
Материал: `git log --oneline origin/dev..HEAD` (25 коммитов) и
`git diff origin/dev...HEAD` (49 файлов). Прочитаны: тело issue #302 и все
комментарии, `docs/specs/302-junction-node-material.md` (текущая редакция,
после правки AC6/§4.5/§8.2 в r2→r3), `docs/WALL-THICKNESS.md` (секции
«Junction nodes» и «Junction tooling»), `docs/SCOPE.md`, `docs/reviews
/CODE-REVIEW-302-r1.md` и `-r2.md` для контекста прежних находок.
## Продуктовая рамка
Задача закрывает белые клинья на стыках стен — прямой отчёт владельца,
регресс из релиза в релиз (docs/SCOPE.md, job «нарисовать и увидеть точный
план» — геометрия стен без визуальных дефектов является базовым требованием
самого продукта, не отдельной фичей). Решение №5 (владелец, тем же днём)
сузило исходную идею «сохранить фаску #249» до «полный mitre везде» по
результатам визуального осмотра собственных эталонов — это законная
итерация внутри одной задачи, а не расширение скоупа.
## Что проверено чтением кода
- `junctionNodeGeometry` (`src/wall-thickness.ts:2032-2158`): один угловой
обход по лучам, отсортированным по азимуту; для каждой пары соседних лучей
— mitre при условии «В СЕКТОРЕ» (`directionOk`, разная проверка для
обычной и рефлексной пары), лимит `MITRE_LIMIT × max(half)` и ограничение
концом толстого саппорта (`thickLength`, #271); иначе — плоская хорда
(рефлекс) либо локальный bevel, у которого `reach()` ограничен саппортом,
лимитом и удвоенной глубиной пары. Ни одного вызова `difference()`/`subtract`
в этой функции — подтверждено grep по телу функции. Соответствует §8.2
буквально.
- `junctionContractHoles` (`:2178-2226`) и живая проверка в
`demo/smoke_junction_holes.mjs`: проба — дыра, если она в полосе луча ИЛИ
в веере узла (контрактное покрытие §8.4), но не в самой геометрии; отдельно
клиппуется `bound` (фасадная граница). Самопроверка «детектор не слеп»
— юнит-тест `test/wall-thickness.test.mjs:213` — гоняет детектор на заведомо
дырявой фикстуре.
- `junctionNodeBound` (`:3197-3226`): та же `exteriorEnvelopeGeometry`, но с
пустой узловой картой — то есть «гладкий» контур без узловых вырезов;
используется, чтобы куски узла не отращивали фасад на вогнутой вершине
(concave-Split контракт).
- Единственное вычитание узловой механики — адресный трим #271
(`src/wall-thickness.ts:3538-3556`): `needsTrim()` фильтрует узлы с
вырожденно коротким толстым саппортом (`support.length < support.halfDepth
* 2`) и вызывает `bevelMultiWallBody` ТОЛЬКО на этом подмножестве — не на
всех узлах. AC6 подтверждён по коду, не по комментарию.
- Бумага (`floorFootprintGeometry`, `paperRoomShapesWithWalls`): вызовы
`bevelMultiWallPaper` убраны полностью (функция удалена — `grep
bevelMultiWallPaper src/wall-thickness.ts` даёт 0 совпадений); паперная
геометрия — просто `rawPaperGeom`, с комментарием, что footprint∪shell уже
покрывает каждый узел (измерено в r2 на #197 и репро, байт-в-байт).
- `dropDegenerateRings` (`:3159-3176`): убирает вырожденные (площадь ≤ eps)
кольца, которые boolean union оставляет там, где хорда веера совпадает с
хордой старой фаски — иначе они топологические дыры для потребителей,
считающих кольца.
- Оба рендерера получают тело из одного и того же
`wallBodiesUnionPath` — точка входа не менялась, только тело функции.
## Находка
### Low — стек-документация `JunctionNodeGeometry` описывает не тот контракт
`src/wall-thickness.ts:2012-2025`, докстринг перед
`export interface JunctionNodeGeometry`:
> «One angular walk produces both halves of the corner rule: a FAN per pair
> of adjacent rays…; **a CUT per over-limit pair — the wedge beyond that same
> chord, which is how the approved #249 chamfer looks**. Fan and cut of one
> pair meet exactly at the chord and never overlap…»
Комментарий описывает механизм с ДВУМЯ артефактами — аддитивным веером и
вычитающим «cut» (то есть фаску #249 как вычитание). Фактический интерфейс,
объявленный прямо под этим текстом, содержит только `fans` и `supports` —
поля `cuts` не существует, и сама функция `junctionNodeGeometry` не вызывает
`difference()` ни разу (подтверждено чтением всего тела функции). Это ровно
тот контракт, который decision №5 и AC6 фиксируют как обязательный: «единственное
вычитание узловой механики — адресный трим #271», а не «per over-limit pair».
Проверено `git log -L2012,2030:src/wall-thickness.ts`: комментарий написан в
самом первом WIP-коммите (`a1361974`, «junction node gets back what a chamfer
must never eat» — до decision №5, когда фаска #249 ещё сохранялась как
вычитающий слой) и ни разу не тронут дальнейшими коммитами decision №5
(`aac50151`) и «fans are the whole additive node» (`d3ed299d`), которые
убрали cut-половину механизма из кода, но не из докстринга.
**Почему Low, а не Medium.** Правило 11 PROCESS.md («документация в том же
коммите, что поведение») касается продуктовой документации
(`docs/**`, changelog, USER-GUIDE) — она вся актуальна и уже дважды сверена
построчно (r1 M1 закрыл `docs/WALL-THICKNESS.md`, r2/r3 M3 закрыл текст
спеки). Здесь — внутренний докстринг исходника, не читаемый пользователем и
не являющийся доказательством ни одного AC (AC6 доказан кодом точки вызова
`bevelMultiWallBody` и мутантом `junction-fan-limit-back-to-249`, не
комментарием). Поведение корректно, тесты и мутанты это подтверждают
исполнением. Риск — исключительно в том, что следующий разработчик,
читающий именно этот файл, ошибётся насчёт контракта «нет вычитаний».
**Решение.** Снято с записью, без правки: находка не в скоупе обязательной
правки этого раунда (Low), но дёшева — 6 строк докстринга, переписывающих
«a CUT per over-limit pair… #249 chamfer looks» на «no cut — the fan always
closes either the mitre or the bounded bevel chord, additively». Рекомендую
поправить при следующем touch этого файла.
## Инвариант модели (#254)
Диапазон меняет геометрию (стыки, `wallIntervals`, узловые записи толщины).
`npm test` уже гоняет инварианты на всех фикстурах проекта — 1304/1304 pass.
Отдельно прогнан `npm run invariants -- --config
test/fixtures/302-junction-artifacts.json` (фикстура-репро владельца, 5
узлов, острые углы 15–70 см): «Инварианты выполнены: ссылки разрешимы,
записи толщины находятся» — ключ записи толщины проверен на точное
совпадение со строкой решёточного ребра, не на допуск.
## Гейты — что прогнал сам
| гейт | результат |
|---|---|
| `npx tsc --noEmit` | чисто |
| `npm test` | 1304 pass / 1 skip / 0 fail |
| `npm run build` + сверка бандла | `git status` после пересборки чист — обе копии (`dist/`, `custom_components/houseplan/frontend/`) байт-в-байт совпадают с закоммиченными |
| `node scripts/check-docs.mjs` | passed (7 файлов, 10 внешних ссылок) — обязателен, diff трогает `src/**` |
| `npm run invariants -- --config test/fixtures/302-junction-artifacts.json` | passed |
| `node scripts/process-gate.mjs` (диапазон `origin/dev..HEAD`, 25 коммитов) | «гейт пройден, предупреждений 0» |
| 8 junction/partition-мутантов (`junction-fans-disabled`, `junction-fan-ignores-thick-length`, `junction-reflex-outer-mitre-missing`, `junction-fan-limit-back-to-249`, `junction-detector-blind`, `junction-pieces-unbounded`, `partition-merge-ignores-junction`, `junction-checks-room-vertices-only`) | каждый лично прогнан `node scripts/mutation-gate.mjs --id=<id>` → «поймано 1 из 1» |
| трейлеры (`Issue:`, `User-Visible:`, `Release:`, `Baseline-Reviewed:`) | проверены на всех 25 коммитах; оба `User-Visible: yes` (`76d60b3e`, `aac50151`) правят `docs/CHANGELOG.md`+`docs/CHANGELOG.ru.md` в том же коммите |
## Гейты — не прогнал сам, компенсировано CI
`npm run golden:verify` и браузерные смоки (`demo/smoke_*.mjs`) в этой
песочнице не запускаются физически: `page.waitForFunction: Timeout 30000ms
exceeded` / `Failed to fetch dynamically imported module`. Это не следствие
изменений задачи — воспроизвёл ту же ошибку на чистом `origin/dev`
(отдельный клон, `npm ci`, `node demo/smoke_decor.mjs`) — сетевое/браузерное
ограничение самой ревью-песочницы, то же самое, что документировал r2.
Компенсация — CI-прогон на `d3927f2e` (третий коммит ребейза; тот же код,
что на HEAD `96318fb9` — разница только `docs/images/screenshots.json`,
подтверждено чтением diff):
- job `golden` (`97846497668`): 126/126 «passed», включая все 16
junction-сцен и обе `safe-resize-handles-clamp-{dark,light}` — прочитан
полный лог, посчитано `grep -c passed` = 126, ни одного «failed»/«diff».
- три шарда `smoke` (`97846497632/522/569`): `smoke_junction_holes`,
`smoke_near_orthogonal_junction`, `smoke_wall_junctions`,
`smoke_junction_patch_resilience`, `smoke_multiwall_junction` — все `ok`;
189 смоков суммарно по трём шардам, единственные строки с «FAIL»/«not ok»
в логе — это исходный текст скрипта (`echo "FAIL $name"`) и смок с
говорящим именем `smoke_glow_fail_dark`, который сам зелёный.
- `performance_smoke` (`97846497544`): success — AC8 (перф-бюджет) закрыт
исполнением, не только «механизм существует».
- `frontend` (`97846156731`): success.
Финальный коммит `96318fb9` легитимно получил «skipped» на этих job'ах через
`reuse` (кэш по хэшу контента) — проверено по `changes`/`reuse` job'ам того
же прогона (`32862032767`), содержимое между `d3927f2e` и `96318fb9`
отличается только некодовым `screenshots.json`.
`node scripts/smoke-select.mjs --base <merge-base> --head HEAD`: прямое
совпадение — `smoke_junction_holes`, `smoke_decor`,
`smoke_grid_scale_invariance`, `smoke_real_plan_masonry`,
`smoke_space_scale_defaults`; зарегистрированная связь —
`smoke_junction_patch_resilience`, `smoke_multiwall_junction`,
`smoke_multiwall_strip_containment`, `smoke_resize_pointer_real_plan`,
`smoke_resize_wall_thickness`. Все перечисленные, кроме
`smoke_real_plan_masonry`/`smoke_space_scale_defaults`/`smoke_decor`
(тематически про `cellCm`, не про узлы), покрыты либо AC9, либо CI-прогоном
выше; отдельно `smoke_real_plan_masonry` тоже был в CI как «прямое
совпадение» по `MITRE_LIMIT» и зелёный там же.
## AC — построчно
| AC | статус | как доказано |
|---|---|---|
| AC1 (0 дыр на всём сете §13) | ✅ | `junctionContractHoles` читан построчно; CI golden 126/126 включает все 16 сцен + репро; юнит «the owner repro is hole-free end to end» зелёный локально |
| AC2 (репро владельца, 5 узлов) | ✅ | `smoke_junction_holes` (живой рендер, CI ok) + юнит-тест на той же фикстуре, локально зелёный |
| AC3 (несвязанные сцены — побайтно; junction — легально) | ✅ | `baselines-index.json`: изменения ограничены junction-сценами и двумя `safe-resize-handles-clamp-*` (объяснено выше — легитимное слияние с #304); подтверждено CI golden 126/126 |
| AC4 (57°, 50/70 — сплошная кладка) | ✅ | юнит `issue 302 the 57° mixed-thickness pair takes the full mitre (decision #5)` зелёный; репро-фикстура включает именно эту пару |
| AC5 (виртуальный участок не порождает кладку) | ✅ (чтением) | фильтр `ray.halfDepth > 0` в начале `junctionNodeGeometry` исключает нулевые лучи из веерного обхода; сцены `junction-t-virtual-arm`/`junction-x-virtual-through` в CI golden зелёные |
| AC6 (единственное вычитание — трим #271) | ✅ | `needsTrim()` фильтр по коду (см. выше); мутант `junction-fan-limit-back-to-249` лично прогнан — «поймано 1 из 1»; текст спеки §4.5/§8.2 приведён в соответствие в r3 |
| AC7 (оба рендерера — один путь) | ✅ (чтением, архитектура не менялась) | `wallBodiesUnionPath` — одна точка входа |
| AC8 (перф в бюджете) | ✅ | `performance_smoke` CI job success на коде, идентичном HEAD |
| AC9 (существующие смоки стыков) | ✅ | `wall_junctions`, `junction_patch_resilience`, `multiwall_junction` — CI ok; `split_corner_wall`, `zero_divider_taper`, `wall_thickness*` — часть `npm test` 1304/1304 |
## Не проверял отдельно (не требуется этой дельтой)
- Визуальную приёмку 16 junction-сцен глазами (какой именно pixel-diff у
каждой) — доверился golden-порогам CI и текстовому описанию решения №5 в
issue; геометрический контракт проверен чтением кода и детектором, а не
«на глаз».
- Полный browser-smoke матрицу (189 смоков) целиком — не запрашивает ни один
AC; ограничился прямыми совпадениями/AC9/зарегистрированными связями,
все они зелёные в CI на коде, идентичном HEAD.
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`.
## Итог
Один Low, снятый с записью без правки (стек-докстринг `JunctionNodeGeometry`
описывает вычитающий «cut», которого в коде нет — см. находку выше). High: 0,
Medium: 0. Вердикт — зелёный.