docs: review document for #302

Issue: #302
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-25 17:34:19 +03:00
committed by Codex
parent e88abcb138
commit 58c096cef1
+274
View File
@@ -0,0 +1,274 @@
# 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) проверен исполнением и корректен.