26 KiB
CODE-REVIEW-302-r1
Issue: #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 jobperformance_smokeреально выполнился и был зелёным наb95c55d3(см. ниже), а не «reuse»-пропущен.
CI, названный автором. Прогон 32850091121
на b95c55d3 (родитель финального docs-коммита): frontend, golden,
performance_smoke, smoke (все три шарда), docs, provenance —
success; process-gate — failure (ожидаемо: before-SHA от форс-пуша,
самовылечился следующим пушем, как и описал автор). Прогон
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/insetContourmitre); включение «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) проверен исполнением и корректен.