Files
houseplan-card/docs/reviews/CODE-REVIEW-302-r1.md
T
2026-08-25 17:34:19 +03:00

275 lines
26 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-r1
Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — «Артефакты на стыках стен: дыры в union-геометрии. Переработка механизма стыков + полноценный сет скриншот-тестов»
Спек: `docs/specs/302-junction-node-material.md` (зелёное ревью ТЗ, `SPEC-REVIEW-302-r1`/`r2`)
Ветка: `issue/302-junction-node-material`, вершина `fa1112e7` (после ребейза на `origin/dev` = `2143e888`, включает #265/#301/#303)
Заход: r1 · блокирующих циклов израсходовано 0 из 4 (этап код-ревью первый; предыдущая попытка `S7-code-review` не состоялась — упёрлась в конфликт ребейза до чтения кода, цикл не расходовался согласно комментарию владельца от 12:43)
Класс изменения: A (продукт) + B (гейты/тесты) + C (документация)
## Скоуп
Полная переработка механики материала в узлах стен (degree-3+, три и более
сходящихся луча): старая смешанная схема «аддитивные патчи + вычитающий
`bevelMultiWallBody`-бевел (#249) с protection-union» заменяется на решение
владельца №5 — **полный mitre по умолчанию** (веер `junctionNodeGeometry`,
mitre в пределах `MITRE_LIMIT`, bevel-хорда за ним, рефлексные сектора
замыкаются обратным mitre или хордой), плюс формальный детектор дыр
`junctionContractHoles` и сет из 16 крупноплановых golden-сцен + сцена-репро
владельца. Старый вычитающий слой `bevelMultiWallBody` не демонтирован
целиком, а сужен до адресного латерального трима для вырожденных
короткосаппортных узлов (#271); `bevelMultiWallPaper` из пути бумаги убран
полностью.
Диапазон: `git diff origin/dev...HEAD` — 47 файлов, ядро в
`src/wall-thickness.ts` (+351/-… строк), тесты (`test/wall-thickness.test.mjs`,
новая фикстура `test/fixtures/302-junction-artifacts.json`), новый смок
`demo/smoke_junction_holes.mjs`, обновлённый `demo/smoke_multiwall_junction.mjs`
и `demo/smoke_grid_scale_invariance.mjs`, 7 новых мутантов
`scripts/mutation-gate.mjs`, 16 новых + 2 изменённых golden-эталона,
`docs/WALL-THICKNESS.md`, оба CHANGELOG.
Это первый содержательный проход код-ревью по этой задаче (первая попытка
`S7-code-review` вернулась конвейером до чтения кода из-за конфликта
ребейза — цикл не расходован, документ по дельте не нужен), поэтому разбор
полный, а не по дельте (§2.10 не применяется).
## Как проверялось
Материал — коммиты `git log --oneline origin/dev..HEAD` (14 штук) и
`git diff origin/dev...HEAD`. Выполнено на этом дереве (SHA `fa1112e7745e0ba0`),
не на слово автора.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | чисто |
| unit | `npm test` | 1303 pass / 1 skip / 0 fail (1304 объявлено) |
| build + сверка бандла | `npm run build && cmp dist/… custom_components/…` | идентичны; `npm run bundle:sync` не изменил рабочее дерево (git status чист) |
| docs screenshots fingerprint | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» — обязателен, диф трогает `src/**` |
| инварианты модели (диф трогает геометрию узлов/`walls`) | `npm run invariants -- --config <репро #302, обёрнутое {space:{...}}>` | «Инварианты выполнены: ссылки разрешимы, записи толщины находятся» |
| golden | `npm run golden:verify` | **126/126 passed**, локально, целиком (совпадает с заявкой автора) |
| смоки — прямые совпадения `smoke-select.mjs --base origin/dev --head HEAD` | `smoke_junction_holes`, `smoke_real_plan_masonry`, `smoke_grid_scale_invariance` | все OK |
| смоки — AC9 плюс «зарегистрированная связь» | `smoke_multiwall_junction`, `smoke_wall_junctions`, `smoke_junction_patch_resilience`, `smoke_render_perf` | все OK |
| мутанты #302 (выборочно, `--id=`) | `junction-fan-limit-back-to-249`, `junction-detector-blind`, `junction-pieces-unbounded`, `junction-supports-not-restored` | 4/4 корректно ловят поломку («поймано 1 из 1» / «поймано 0 из 1» для guard без мутации, как обязано) |
| мутант `junction-fans-disabled` | `node scripts/mutation-gate.mjs --id=junction-fans-disabled` | **упал на чистом прогоне** — см. находку M1 |
| process-gate офлайн | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
| CI на вершине | `gh run view` на прогонах, указанных автором | подтверждено см. ниже |
Не прогонялось и почему:
- `python -m pytest tests_backend` — диф не трогает `custom_components/**/*.py` (не нужен по правилу гейтов);
- полный `demo/smoke_*.mjs` (189 файлов) — задача не «задевает всё», выборка
дана `smoke-select.mjs` (5 прямых + 2 «зарегистрированная связь») плюс AC9;
расширил её `smoke_render_perf` (AC8) вручную;
- `npm run mutants` целиком (все ~90 мутантов проекта) — предрелизный гейт
(§8), не гейт ревью; из новых семи прогнал четыре показательных плюс
специально диагностировал сломанный;
- полный performance-бенчмарк (`npm run benchmark:*`) — дорогой,
предрелизный; вместо повторного прогона проверил, что CI job
`performance_smoke` реально выполнился и был зелёным на `b95c55d3` (см. ниже),
а не «reuse»-пропущен.
**CI, названный автором.** Прогон [32850091121](https://github.com/Matysh/houseplan-card/actions/runs/32850091121)
на `b95c55d3` (родитель финального docs-коммита): `frontend`, `golden`,
`performance_smoke`, `smoke` (все три шарда), `docs`, `provenance` —
`success`; `process-gate` — `failure` (ожидаемо: before-SHA от форс-пуша,
самовылечился следующим пушем, как и описал автор). Прогон
[32850748475](https://github.com/Matysh/houseplan-card/actions/runs/32850748475)
на финальном `fa1112e7` — `success` целиком; тяжёлые job'ы там `skipped`
через легитимный `reuse` (доки — единственный дифф этого коммита, не
затрагивающий их отпечаток). Вместе оба прогона покрывают полный набор на
проверяемом дереве, как и заявил автор.
## AC — разбор
| AC | Статус | Доказательство |
|---|---|---|
| AC1 (детектор: 0 дыр на всём сете) | ✅ | golden verify 126/126 включает все 16 сцен + репро; `smoke_junction_holes` (прямое исполнение) `noContractHoles: true`; юнит-тест «the owner repro is hole-free end to end» зелёный |
| AC2 (репро владельца: 0 дыр) | ✅ | тот же смок + golden `junction-owner-repro-dark` passed |
| AC3 (несвязанные сцены — побайтно; junction-сцены — легально изменены) | ✅ | `baselines-index.json`: только 16 новых + 2 изменённых (`safe-resize-handles-clamp-*`, названо автором как узловая вершина ромба) хэша; остальные 108 не тронуты — подтверждено чтением индекса |
| AC4 (57°, 50/70 — сплошная кладка) | ✅ | юнит «the 57° mixed-thickness pair takes the full mitre (decision #5)» проверяет ровно эту геометрию (углы 45°/102.3°/332.2°, half 4.861/3.472) |
| AC5 (виртуальный луч не порождает кладку) | ✅ (чтением) | `junctionNodeGeometry`: `rays = node.rays.filter(ray.halfDepth > 0)` — нулевой/виртуальный луч исключён из веерного обхода до сортировки по азимуту, соседи по азимуту становятся его соседями автоматически; golden `junction-t-virtual-arm-dark`/`junction-x-virtual-through-dark` в сете |
| AC6 (узловая механика не содержит `difference`) | ⚠️ частично — см. находку M1/M2 | Буквально неверно: `bevelMultiWallBody` (внутри — `difference`) по-прежнему вызывается для узлов с вырожденно-коротким толстым саппортом (`corePhase = 'multi-wall-trim'`, `wall-thickness.ts:3600-3613`). Регресс-мутант, названный в спеке для этого AC («лимит веера 1.25·h»), проверен и ловится (`junction-fan-limit-back-to-249`). Мутант, названный в спеке для «веера не строятся вовсе», сломан на инфраструктурном уровне — см. M2 |
| AC7 (оба рендерера — один вызов) | ✅ (чтением) | `wallBodiesUnionPath` (единственная точка, `wall-thickness.ts:3714`) вызывается и из `houseplan-card.ts:11692`, и из `space-render.ts:432`; архитектура не менялась, только тело функции |
| AC8 (перф в бюджете) | ✅ с оговоркой | `smoke_render_perf` OK; CI `performance_smoke` реально выполнился и зелёный на `b95c55d3` (не reuse-пропуск, проверено по списку job'ов). Явного числового «замера large-house» в хендоффе нет, спека требовала это отдельной строкой — не блокирует (Low, механизм проверки существует и сработал) |
| AC9 (существующие смоки стыков зелёные) | ✅ | `wall_junctions`, `junction_patch_resilience`, `multiwall_junction`, `real_plan_masonry` — все прогнаны лично, OK |
## Находки
### Medium (в скоупе) — M1: `docs/WALL-THICKNESS.md` не переписан под решение №5, хотя спека требовала это явно
Файл: `docs/WALL-THICKNESS.md:188-196`.
Абзац «**Junction nodes (#302).**» по-прежнему гласит: *«A degree-3+ node
keeps the approved #249 chamfer (`bevelMultiWallBody`, bounded by the node's
join limit `MULTI_WALL_JOIN_LIMIT × halfDepth`), and AFTER it the node
additively gets back what a chamfer must never eat…»* — это описание
**отменённого** утреннего решения «сохранить фаску #249», а не итогового
решения №5 («узлы смыкаются полным mitre… фаска #249 демонтируется целиком»,
`docs/specs/302-junction-node-material.md:65-73`). Фактическое поведение (по
чтению `wall-thickness.ts:3591-3634`) обратное описанному в доке: фаска
(`bevelMultiWallBody`) теперь **опциональна и включается только** для узлов
с вырожденно-коротким толстым саппортом (`needsTrim`), а не «для каждого
degree-3+ узла» как написано; для всех остальных узлов работает чистый
аддитивный веер, что и есть содержательное отличие этой задачи.
Коммит `e8338663` («feat: full mitre at every node — the #249 chamfer
retires», `User-Visible: yes`, решение №5) не тронул `docs/WALL-THICKNESS.md`
вовсе (`git show --stat e8338663` не содержит этот файл). Единственный
последующий докс-коммит `fa1112e7` добавил только раздел «Junction tooling»
(список тестов), не исправив основной абзац.
Нарушает:
- `PROCESS.md` правило 11: «Документация — в том же коммите, что поведение.
Отдельным «допишу потом» коммитом документация не бывает» — коммит,
сменивший контракт (`e8338663`, User-Visible: yes), не обновил канонический
документ подсистемы;
- собственное требование спеки, §15: «`docs/WALL-THICKNESS.md` §3 и §9
переписываются под новую механику» — раздел §9 (Independent partitions)
тоже не упоминает #302 вовсе, что ожидаемо (узел независимых партиций не
трогается), но §3 обязан был обновиться и не обновился.
Почему это находка, а не педантизм: этот файл — канонический документ
подсистемы, который явно предписано читать перед любой работой над стенами
(`AGENTS.md`, сам этот ревью начиналось с его чтения). Ложное описание
«узел всегда получает старую фаску» уведёт следующего агента/автора по
неверному следу при следующей задаче на стыках.
Воспроизведение: `git diff origin/dev...HEAD -- docs/WALL-THICKNESS.md` —
всего 24 добавленные строки, из которых 15 — новый раздел про тестирование;
абзац §3 про механику узла не менялся с коммита `9e68d641` (12:33), то есть
раньше решения №5 (15:13).
Фикс: переписать абзац `docs/WALL-THICKNESS.md:188-196` под фактический
контракт — веер по умолчанию (mitre/bevel-хорда, рефлекс), `bevelMultiWallBody`
только как адресный трим для короткосаппортных узлов, `bevelMultiWallPaper`
убран из бумаги целиком.
### Medium (в скоупе) — M2: мутант `junction-fans-disabled` не работает, вопреки заявлению «краснота проверена исполнением»
Файл: `scripts/mutation-gate.mjs:2213-2225`.
```
guard: 'node demo/smoke_junction_holes.mjs',
```
`demo/smoke_junction_holes.mjs` — единственный смок в проекте, импортирующий
напрямую из `../test-build/wall-thickness.js` (`grep -rl "from '../test-build/"
demo/smoke_*.mjs` находит только его), поэтому его guard обязан собрать
`test-build/` первым (как это делают все соседние #302-мутанты:
`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && …`).
У этого мутанта такого префикса нет.
Харнесс сам решает, нужен ли `test-build`, эвристикой
`guardNeedsTestBuild()` (`scripts/mutation-gate.mjs:2920-2922`):
`/(^|[\s&|;])node --test\b/.test(guard) && !guard.includes('tsconfig.test.json')`
— она матчит только guard'ы вида `node --test …`, а не «браузерный» смок,
который тем не менее тоже читает `test-build/`. Результат — воспроизведено
исполнением:
```
$ node scripts/mutation-gate.mjs --id=junction-fans-disabled
FAIL чистый прогон: node demo/smoke_junction_holes.mjs красный без мутанта
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/hp-mutant-uvmoTK/test-build/wall-thickness.js' …
```
Гейт падает уже на «чистом» прогоне (до применения мутации) — это
означает, что мутант не проверял АБСОЛЮТНО ничего ни разу с момента
создания: ни в момент написания, ни в любом гипотетическом прогоне харнесса
он не мог напечатать «поймано 1 из 1». Это прямо противоречит явному
заявлению в теле коммита `e8338663`: *«мутанты переякорены, краснота каждого
проверена исполнением»* и в хендоффе issue: *«восемь мутантов — краснота
каждого проверена исполнением штатным харнесом»*.
Мутант закрывает первую строку таблицы §14 спеки — `node-fan-disabled`
(«веера не строятся вовсе» → «детектор на сете») — самый фундаментальный
регресс-класс новой механики. Сейчас его прикрывает не он, а косвенно
`junction-supports-not-restored` (другой мутант, который частично
пересекается по эффекту — тоже режет вклад аддитивных кусков — и этот
действительно ловится, «поймано 0 из 1» без мутации, «FAIL» ожидаемо на
чистом прогоне... то есть сам по себе он в порядке). Но заявленное покрытие
«веера отключены полностью» не проверено никем.
Не является блокирующим (High), потому что: (а) это дефект тестовой
инфраструктуры, а не продуктового кода — сама геометрия работает и покрыта
множеством других зелёных проверок (детектор, 126/126 golden, все AC9-смоки);
(б) `mutation-gate` — предрелизный, не блокирующий Validate гейт (§8,
`.github/workflows/mutation-gate.yml` — по расписанию/`workflow_dispatch`,
не на каждый push), поэтому CI branch protection этим не введён в заблуждение
формально. Но это Medium в скоупе: спека прямо обещала эту защиту (§14),
коммит прямо заявил, что она проверена, и оба заявления не соответствуют
действительности.
Фикс: добавить в `guard` этого мутанта тот же префикс сборки, что у соседей —
`'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/smoke_junction_holes.mjs'`
— и повторно прогнать `--id=junction-fans-disabled`, чтобы подтвердить
«поймано 1 из 1» на самом деле.
### Low — L1: `bevelMultiWallPaper` осталась мёртвым кодом
Файл: `src/wall-thickness.ts:2955-2984`.
Функция `bevelMultiWallPaper` (вычитающий бевел для контура бумаги) не имеет
вызовов нигде в дереве (`grep -rn "bevelMultiWallPaper" src/*.ts test/*.mjs
demo/*.mjs` находит только её собственное объявление) — коммит `e8338663`
корректно убрал единственный вызов (`paperGeom`/`floorFootprintGeometry`
теперь используют `paperWithNodeCorners`), но само тело функции осталось.
Не экспортирована, поведения не меняет, `tsc --noEmit` её не флагует
(в проекте нет `noUnusedLocals` для верхнеуровневых функций). Снимаю с
записью — правьте свободно вместе с M1, либо отдельной строкой, решение за
автором: это тривиальная уборка, не тянет на отдельный цикл сама по себе.
## Что проверено и корректно
- Основной контракт (веер `junctionNodeGeometry`, границы mitre/bevel,
фасадный клип `junctionNodeBound`, спец-случаи виртуального луча,
коллинеарной пары, degree-1, колонны) прочитан построчно и соответствует
§8.2-8.3 спеки; тесты `test/wall-thickness.test.mjs` («issue 302 …», 6 новых
тестов) целятся именно в эти границы, включая регресс на 1.25·h-лимит
(старый #249) и на вырожденный реверс-mitre (Y-60 из отчёта владельца).
- Формальный детектор `junctionContractHoles` не «слеп»: юнит-тест кормит
ему заведомо дырявое тело (2 из 3 полос) и требует красноты — прошёл;
отдельно мутант `junction-detector-blind` подтверждает то же на живом
харнессе.
- Golden-набор реально расширяет покрытие матрицы §13 почти полностью:
L отсутствует как отдельная сцена, но обоснованно — `buildMultiWallNodeMap`
строит узлы только для `rays.length >= 3` (`wall-thickness.ts:1939`),
обычный двухлучевой угол этим механизмом вообще не обрабатывается (это
зона старого, не тронутого этой задачей `outsetContour`/`insetContour`
mitre); включение «L» в таблицу §13 спеки было избыточным пожеланием, а не
пропущенным требованием — не в счёт находок.
- Трейлеры: оба `User-Visible: yes`-коммита (`9afc410d`, `e8338663`) несут
правки в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же коммите;
формулировка финальной записи («the old junction chamfers are gone»)
описывает видимый результат (визуально фаска исчезла — трим невидим,
работает только на вырожденных геометриях) и не расходится с рендером.
- Golden-коммиты несут `Release:`/`Baseline-Reviewed:` на реальные зелёные
прогоны CI (проверено — оба run ID существуют и относятся к веткам этой
задачи).
- `process-gate.mjs` офлайн — 0 предупреждений; ветка полностью содержит
`origin/dev` (ребейз произведён, конфликтов нет).
- AC3 количественно: индекс эталонов содержит ровно 16 новых + 2 изменённых
хэша из 126 (сверено чтением `baselines-index.json`, не поверил на слово).
## Чего не проверял
- Полный `demo/smoke_*.mjs` (189 файлов) и полный `npm run mutants`
(~90 мутантов) — предрелизные гейты, задача не задевает всё; выборка
обоснована в таблице выше.
- Локальный полный `python -m pytest tests_backend` — диф не трогает Python.
- Числовой перф-профиль large-house вручную не переснимал; доверился
зелёному CI job `performance_smoke` на `b95c55d3` (подтверждено — не reuse).
- Не проверял три «зарегистрированные связи» смока `smoke_decor.mjs` и
`smoke_space_scale_defaults.mjs` (символ `cellCm` — широко используемый,
слабая связь по инструменту) — доверился их независимости от узловой
механики по чтению кода (`cellCm` там используется вне контекста
multi-wall-узлов).
## Вердикт
High: 0 · Medium: 2 (обе в скоупе) → возврат автору, без нового issue (#202).
Оба Medium дешёво чинятся: M1 — переписать один абзац
`docs/WALL-THICKNESS.md:188-196` под фактический контракт; M2 — добавить
build-префикс в guard одного мутанта и подтвердить «поймано 1 из 1»
исполнением. Ни одна находка не требует правки продуктовой геометрии —
сам механизм (AC1-AC5, AC7, AC9) проверен исполнением и корректен.