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

26 KiB
Raw Blame History

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 job performance_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/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) проверен исполнением и корректен.