Files
houseplan-card/docs/reviews/CODE-REVIEW-51-r1.md
T
2026-09-02 21:56:04 +00:00

370 lines
33 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 — issue #51 «Custom decor images» · заход r1
- Issue: https://github.com/Matysh/houseplan-card/issues/51
- Материал: `git log origin/dev..HEAD`, `git diff origin/dev...HEAD`
- SHA материала: `38205d87a6e65bf670e214050223a9e22a9d2a42` (сверено с
`git rev-parse HEAD` непосредственно перед выводом)
- База: `origin/dev` = `e438c25ebe5ffbd4214da473d62a305016a3f3df`
- Ветка приведена к `dev` конвейером до ревью (8 коммитов dev поверх исходного
`c4e45aa9`, итог — единственный ребейзнутый коммит `38205d87`). Это другой код
(§7.2) — разбор ниже полный, не по дельте. Второго раунда до этого не было,
поэтому раздел «Унаследовано из r<N-1>» не применяется.
- Трейлеры коммита `38205d87`: `Issue: #51`, `User-Visible: yes` — оба
changelog правлены в этом же коммите (проверено, см. «Гейты»/AC16).
## Скоуп изменения
Новый decor kind `image`: приватное content-addressed хранилище
PNG/JPEG/WebP/SVG (backend `decor_assets.py`, `http_api.py`, `websocket_api.py`,
`validation.py`, `import_export.py`, `const.py`), одна кнопка «Изображение» в
Background editor, furniture-parity transforms без wall magnet, palette с
явным удалением неиспользуемого файла, отображение в full View и
`houseplan-space-card`, missing-asset repair-placeholder, export v2/import v1
совместимость, i18n RU/EN/DE/FR, документация. 63 файла, ~4200/1200 строк.
Спека `docs/specs/051-custom-decor-images.md` (16 AC) прошла ревью ТЗ зелёным
(`docs/reviews/SPEC-REVIEW-51-r1.md`, единственная находка — Low, косметика).
## Как проверялось
Ревью выполнено чтением всего диффа (`git diff origin/dev...HEAD`) плюс двумя
параллельными агентами: один разобрал backend security (`decor_assets.py`,
`http_api.py`, `websocket_api.py`, `validation.py`, `import_export.py`,
`tests_backend/*`), второй — frontend (`decor-assets.ts`,
`houseplan-editor-runtime.ts`, `space-render.ts`, `space-geometry.ts`,
`houseplan-card.ts`, `backdrop-pick.ts`, `test/decor-assets.test.mjs`,
i18n). Их находки перепроверены мной лично там, где это осмысленно (см. ниже):
самое серьёзное — воспроизведено собственноручно, не принято на слово.
### Гейты — что прогнано и результат
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный |
| Unit | `npm test` | 1775 passed, 1 skipped, 0 failed (1776 подтестов) |
| Build | `npm run build` | зелёный |
| Bundle sync | `npm run bundle:sync` | зелёный, `git status` после — чисто (три копии совпадают) |
| Bundle budget | `npm run bundle:budget` | initial View 291026 B / 300000 B — **прошёл**, но запас 8974 Б < порога 15000 Б (предупреждение скрипта, ссылка на #367). Не блокирует, но следующая фича упрётся в стену бюджета — стоит упомянуть автору |
| Backend pure | `python3 -m pytest tests_backend/test_decor_assets.py -q` (без Pillow/HA в песочнице ревьюера) | 21 passed |
| Golden | `npm run golden:verify` | 153/153 passed — существующие сцены не пострадали от нового decor-слоя |
| Docs fingerprint | не прогонялся отдельно — `npm run build`/`bundle:sync` не жаловались; diff `src/**` есть, но `docs`-скриншот-гейт CI не эмулировался локально (нет соответствующего npm-скрипта в перечне пройденных мной команд) | **не проверял**, см. «Чего не проверял» |
| Model invariants (`npm run invariants`) | не прогонялся | diff не трогает `marker.space`, `open_spans`, thickness-записи, `layout` (проверено grep по `src/` и `custom_components/houseplan/*.py` — совпадений нет); decor — не геометрия комнат/стен. Инварианты сочтены нерелевантными, не пропущенными |
| smoke_decor_images.mjs, smoke_decor_layer_order.mjs, smoke_furniture.mjs (названы автором) | `node demo/<file>` | OK (переподтверждено) |
| Дополнительные smoke по `smoke-select.mjs` (см. ниже) | `node demo/<file>` × 11 | 10 зелёных, **1 красный — см. Finding 2** |
| `python -m pytest tests_backend/test_ha_websocket.py test_ha_import_export.py` (полный HA-harness) | — | **не прогонял**: нет `homeassistant`/Pillow в песочнице ревьюера (по канону AGENTS.md — Linux CI/WSL). Разобрано чтением (см. ниже) |
### Выбор дополнительных smoke (`scripts/smoke-select.mjs --base origin/dev --head HEAD`)
Инструмент: изменено 10 файлов `src/**`, 146 символов на изменённых строках,
порог «широкого» символа — 42. 67 файлов дали **прямое совпадение**, 48 —
слабую связь. Полный прогон всех 67 непропорционален задаче (диапазон широк
из-за общих символов `_curSpaceCfg`/`_mode`/`_config`/`_saveConfig` — они везде).
Прогнал 11 из прямых совпадений, выбранных по риску: `smoke_decor.mjs`
(общий decor-контракт), `smoke_furniture_polish.mjs` (transform-код теперь
общий с image), `smoke_grid_snap.mjs`, `smoke_hide_layers.mjs` (AC5
`hide_decor`), `smoke_wall_junctions.mjs` (**прецедент #234** — по имени про
стыки стен, ранее ловил регресс толщины из внешне не связанного изменения),
`smoke_backdrop_guard.mjs` (переиспользован в `backdrop-pick.ts` для
upload-диалога), `smoke_space_card.mjs`, `smoke_space_card_bg.mjs` (AC5 static
card parity), `smoke_sign_cap.mjs`, `smoke_plan_signed.mjs` (contentsigning),
`smoke_infinite_canvas.mjs` (`decorBoxItem`/«Вписать всё»).
Результат: 10 OK, **1 упал** — `demo/smoke_decor.mjs` (см. Finding 2). Не
прогонял оставшиеся ~56 прямых совпадений и все 48 слабых связей — не
пропорционально задаче; полный набор остаётся обязанностью пре-релиза.
## Находки
### Finding 1 (High) — SVG-санитайзер обходится кодировкой UTF-16: entity-bomb достигает `ET.fromstring` в обход собственной защиты от DTD/entity
**Файл:** `custom_components/houseplan/decor_assets.py:161-172`.
Проверка DTD/entity/PI — это префильтр по байтовым ASCII-подстрокам
(`b"<!doctype"`, `b"<!entity"`, `b"<?xml-stylesheet"`, а также отдельная
проверка `b"<?" in without_declaration`) поверх `data.lower()`. Она ничего не
находит, если файл закодирован в UTF-16 (каждый ASCII-символ перемежается
нулевым байтом), поэтому строка `b"<!doctype"` физически отсутствует в байтах,
хотя после декодирования это валидный `<!DOCTYPE`. `ET.fromstring(data)` в
строке 170 — это `xml.etree.ElementTree` поверх expat, который **сам
автоматически определяет кодировку по BOM** и раскрывает DTD/entity без
собственной защиты от экспоненциального разрастания (в отличие от
`defusedxml`, который здесь не используется нигде).
**Воспроизведено лично** (не только со слов агента): собрал classic
"billion laughs" (5 уровней вложенности `<!ENTITY a0>`…`<!ENTITY a4>`,
исходный размер ~800 байт), закодировал в UTF-16, скормил
`custom_components/houseplan/decor_assets.validate_asset`. Результат:
файл ушёл дальше строки 170 (`ET.fromstring` успешно раскрыл entity — сам
разросшийся текст, ~32 000 символов, дошёл до элемента `<title>` и был отклонён
**только** последующей проверкой длины текста `title`/`desc` — то есть
раскрытие уже произошло и уже стоило памяти/CPU **до** того, как сработал
любой из декларированных в ТЗ лимитов «element/depth/attribute» (они
применяются в цикле по уже построенному дереву, а не во время парсинга).
Если поместить растущую сущность не в `<title>`, а в атрибут вроде `d`, до
неё вообще не дойдёт ни один из этих лимитов раньше, чем `ET.fromstring`
целиком построит раздутое дерево в памяти — при нескольких дополнительных
уровнях вложенности из файла кратно меньше 2 МиБ получается экспоненциальный
рост в память/CPU, всё в HA executor-потоке (не фризит event loop, но реально
грузит воркер и память процесса HA — то есть DoS на типичном для HA
маломощном хосте).
Это прямой пробой явно заявленного в ТЗ контракта («XML parser запрещает DTD,
entity declarations/resolution» и «Risks»/«Streaming byte cap ставится до
parse, затем element/depth/... limits») — контракта, который является
центральным для issue с меткой `security`. Ни один существующий тест не ловит
этот класс обхода: `tests_backend/test_decor_assets.py:38` содержит DOCTYPE/
entity-кейс, но только в ASCII-кодировке — «тест умеет падать» на прямой ASCII
атаке, но не умеет упасть на этой (кодировочной) вариации, а значит не
доказывает заявленный AC7 целиком.
**Направление фикса** (не мне решать точный путь — это техническое
предположение автора): использовать `defusedxml.ElementTree.fromstring` (или
явно сконфигурировать expat-парсер так, чтобы `StartDoctypeDeclHandler`/
`EntityDeclHandler` фейлили закрыто) вместо байтового префильтра — это
единственный способ не зависеть от кодировки на входе.
**Блокирует.** AC7 не может считаться доказанным до исправления.
### Finding 2 (Medium, в скоупе) — диф ломает существующий, не относящийся к задаче smoke: `demo/smoke_decor.mjs`
**Файлы:** `src/houseplan-card.ts` (диф убирает делегатор), `demo/smoke_decor.mjs`
(не тронут этим diff, но полагается на убранный метод).
`git diff origin/dev...HEAD -- src/houseplan-card.ts` убирает из класса
`HouseplanCard` тонкие делегаторы `_furnPlace`, `_furnPick`, `_furnFieldValue`,
`_furnFieldToCm`, `_furnMoveUpdate`, `_decorApplyBox`, `_renderFurnPalette`,
`_renderFurniturePlacementPreview` и др. (были вида
`private _furnPlace(...) { return this._editorRuntimeOrThrow()._furnPlace(...); }`)
— методы теперь живут только внутри `HouseplanEditorRuntime` и вызываются как
`this._furnPlace(...)` изнутри самого runtime, что не ломает ни один реальный
пользовательский сценарий (пользователь не видит разницы). Но
`demo/smoke_decor.mjs:192` вызывает `c._furnPlace(...)` напрямую на элементе
карточки — это существующая (не относящаяся к image) конвенция теста.
Автор **знал** про эту архитектурную смену: `demo/smoke_furniture.mjs` в этом
же diff переписан с `c._furniturePreviewPlacement` на
`c._editorRuntime?._furniturePreviewPlacement()` ровно по этой причине. Но
`smoke_decor.mjs` пропущен — не входил в список прогнанных автором smoke
(«node demo/smoke_decor_images.mjs — OK; …smoke_decor_layer_order.mjs — OK;
…smoke_furniture.mjs — OK» — `smoke_decor.mjs` в списке нет).
**Воспроизведено лично, дисциплина «тест умеет падать» подтверждена в обе
стороны:**
- на `HEAD` (`38205d87`): `node demo/smoke_decor.mjs` → `TypeError: c._furnPlace
is not a function`, процесс падает с ненулевым кодом;
- на базе (`origin/dev` = `e438c25e`, отдельный `git worktree`, свежая
пересборка): тот же smoke проходит полностью, `OK`.
Других мест, вызывающих остальные убранные делегаторы напрямую на `c.`, не
нашлось (`grep` по `demo/*.mjs test/*.mjs` — пусто) — поражение точечное,
единственный файл.
Это не поведенческий дефект для пользователя, но это красный существующий
гейт, который переживёт этот раунд ревью незамеченным, если его не запустить
специально (что и произошло — автор его не запускал), и всплывёт при
следующем полном пре-бета прогоне ровно по сценарию #230/#234/#237 («красный
job до следующей задачи»). Фикс механический — тот же паттерн, что уже
применён к `smoke_furniture.mjs`. В скоупе задачи (тот же файл
`houseplan-card.ts`, тот же коммит), чинится в этом же issue, отдельный issue
не заводится (#202).
### Finding 3 (Medium, в скоупе) — «единая проекционная функция» AC5 не буквальна: рендер дублирован между View и Background editor
**Файлы:** `src/houseplan-card.ts:8762-8779` (Background editor слой) и
`src/space-render.ts:636-655` (View/space-card слой).
Оба места независимо собирают одну и ту же трансформ-строку
(`translate(cx cy) rotate(angle) scale(±1 ±1) translate(-cx -cy)`) и клэмп
`opacity` — но как две раздельные реализации, а не единая функция, которую оба
вызывают. Спека прямо требует: «Полный View и `houseplan-space-card`
используют одну **projection-функцию**». `houseplan-card.ts` использует
`normalizeAngle`, `space-render.ts` — свой `Number(shape.angle) || 0`; сегодня
они численно эквивалентны (период вращения, одинаковая трактовка нечисловых
значений), видимого дефекта нет.
Это ровно класс проблемы из «Одно число — один источник»: #233/#234 родились
из двух независимых формул для «одного и того же» значения, которые молча
разошлись при последующей правке одной из копий. Конкретный сценарий отказа:
кто-то поправит клэмп `opacity` в одном файле (например, изменит fallback с 1
на «унаследовать предыдущее значение») и не тронет второй — тогда full View и
`houseplan-space-card` отрисуют один и тот же `DecorImage` с разной
прозрачностью, и ни один unit-тест этого не поймает (существующий
`furniture-stroke-contract` проверяет паритет preview/placement для мебели,
не paritet View/static-card для image). Единственная защита сегодня — ручное
внимание при следующей правке.
В скоупе (оба файла в этом же diff), чинится в этом же issue сведением к одной
экспортируемой функции проекции.
### Finding 4 (Medium, в скоупе) — нет negative/positive-кэша resolve по `asset_id`; явное требование производительности из ТЗ не выполнено
**Файл:** `src/decor-assets.ts` (`resolveDecorAssets`, batching по 200,
дедупликация `Set` — есть), вызовы `houseplan-card.ts:4310` и
`space-card.ts:732`.
Спека, раздел «Производительность»: «Missing ids также negative-cache-ятся на
текущую config revision». Кэша (ни отрицательного, ни положительного) нет:
`_syncDecorAssets`/`_load` перевызывают `houseplan/assets/resolve` при каждом
`onConfigChange`/`_reloadConfigOnly`, включая случаи, когда набор
`decorAssetIds` не изменился и/или id уже был подтверждён отсутствующим
секундой раньше. Конкретный сценарий: план с одним missing image; любая
структурная правка в редакторе (подвинуть мебель, переименовать комнату) шлёт
`resolve` заново на тот же уже-известный-missing id — вместо нуля запросов на
не относящуюся к декору правку получаем один WS-вызов на каждое изменение.
AC14 (1000 записей, ограниченный resolve) формально проходит благодаря одной
только батч-дедупликации на батч в 200 — тест не проверяет повторные вызовы
между разными конфиг-ревизиями, поэтому пропускает именно то отклонение,
которое здесь описано.
В скоупе (тот же новый файл), не архитектурный блокер, но явно
недовыполненный пункт спеки — годится для правки в этом же issue.
### Finding 5 (Low) — тач-committed размещение всегда использует mouse-допуск снапа, не touch
`src/houseplan-editor-runtime.ts:5130-5150`: `_decorImagePlace` вызывает
`this._decorSnap(raw)` без `pointerType`, получая допуск по умолчанию
(`'mouse'`, ~8px) даже когда коммит пришёл с тач-пути (`_furnPointerUp`
не прокидывает `pending.pointerType`, в отличие от `_furnPlace`, который
явно форвардит его дальше). Практический эффект — тач-размещение картинки
получает более тесный магнит, чем предусмотренный touch-радиус (~14px);
паритет с мебелью небольшой, не架构ный. Можно поправить или снять с записью.
### Finding 6 (Low, информационно, не находка) — SVG: числовые/строковые bounds отдельных атрибутов не проверяются, только допустимость имени
`custom_components/houseplan/decor_assets.py` ограничивает суммарный бюджет
символов атрибутов (`MAX_SVG_ATTR_CHARS=512000`) на весь документ, но не
проверяет диапазон/конечность значения отдельных атрибутов (`transform`,
`stroke-width`, `opacity` и т. п. — только их допустимость по имени). Спека
формулирует это как «числовые/строковые bounds» для этих атрибутов. Поскольку
SVG вставляется только как `<image>` (изолированный растровый контекст в
браузере, не inline DOM — см. п. 6 «Отображение и слои»), практический риск —
не XSS/SSRF, а максимум деградация рендера самого файла в собственном
изолированном контексте. Не блокирует, не Same класс, что Finding 1 (там
пробита реальная защита от конкретной атаки; здесь просто не реализован явно
заявленный, но не критичный по факту изоляции пункт спеки). Годится либо
поправить, либо явно снять с owner-note в спеке «принято предположительно».
### Finding 7 (Low, информационно) — javascript:/data:-substring проверка в SVG сама по себе тривиально обходима, но не является рабочей защитой
`decor_assets.py:207-209`: проверка `low` на подстроки `javascript:`, `data:`,
`http:`, `https:`, `//` теоретически обходится непечатным символом внутри
схемы (например, табуляцией), но это не единственная защита — `href`
дополнительно ограничен строгим `^#id`, и ни один другой allowlisted атрибут не
трактуется как исполняемый URL-приёмник. Не эксплуатируется при текущем
allowlist. Отмечено для полноты, не как отдельный блокер.
## Проверено и корректно (по AC)
- **AC1** (кнопка/palette/one-shot) — подтверждено чтением +
`demo/smoke_decor_images.mjs` (прогнан, OK) + переподтверждено фронтенд-агентом
построчно (`houseplan-editor-runtime.ts:5875`, `:5130-5148`, `:5909`).
- **AC2** (100 см / aspect / cap 200) — `initialDecorImageCm`
(`src/decor-assets.ts:66-76`) единая функция и для preview, и для commit;
unit `test/decor-assets.test.mjs` покрывает cap-кейс реальными числами
(400×200→100×50, 100×400→50×200 с реальным capping, не тавтология).
- **AC3** (furniture-parity, без wall magnet, hit-area — весь прямоугольник) —
подтверждено: wall magnet явно гейтится `kind === 'furniture'`
(`houseplan-editor-runtime.ts:4316`), image идёт через общий
decor/room-магнит; `pointer-events: bounding-box` в
`src/styles/plan.styles.ts` для `.dimage`/`.dimage-missing`. Демо-негативный
свидетель есть (`noFurnitureWallMagnet`), хоть и не самый сильный (см. отчёт
агента — не заводит отдельную находку, слабое место без конкретного сценария
отказа).
- **AC4** (properties/replace/no-delete-on-replace) — подтверждено чтением
(`_decorSaveShape` пишет только по `shape.id`, replace меняет только
`asset_id` выбранного объекта) + smoke.
- **AC5** (общий рендер, hide_decor, пассивность) — hide_decor и pointer-inert
подтверждены в обоих файлах; «одна проекционная функция» — см. Finding 3
(дублирование, не расхождение сегодня).
- **AC6** (raster/SVG сигнатуры, лимиты, атомарность) — подтверждено чтением
`decor_assets.py`/`http_api.py` + `test_ha_websocket.py` (согласно отчёту
агента: concurrent-upload dedup тест реально бы упал без lock/atomic-rename)
— проверено чтением, не исполнением (HA-harness недоступен в песочнице
ревьюера).
- **AC7** (SVG allowlist, sandbox headers) — **не полностью доказан**, см.
Finding 1 (High). Заголовки (exact MIME, nosniff, sandbox CSP) подтверждены
чтением `http_api.py:195,211-213`.
- **AC8** (content-addressed id, dedup) — подтверждено: `asset_id` есть
sha256 канонических байт (`decor_assets.py:71-72`), resolve/GET
перепроверяют хэш на каждое чтение.
- **AC9** (server-side refcount, in_use, no auto-cleanup) — подтверждено:
delete не принимает client refcount вообще, пересчитывает под
`write_lock`+`upload_lock` из `config_store` (`websocket_api.py:1164-1190`);
`test_ha_websocket.py` содержит race-тест на `in_use` и идемпотентность —
проверено чтением утверждений теста, не исполнением.
- **AC10** (missing asset/repair) — подтверждено: View возвращает `[]` при
пустом `href`, editor рисует selectable placeholder с «Заменить»; smoke
покрывает.
- **AC11** (export v2/import v1, manifest без байт) — подтверждено чтением
`import_export.py`: `_internal_path` строгий regex на 64-hex+расширение
(защита от path traversal), `content_manifest` пересчитывает hash с диска
(не доверяет заявленному), импорт помечает `missing_preserved` без удаления
geometry.
- **AC12** (rolling compatibility) — backend: схема аддитивна (`vol.Any`),
`decor_assets_api: 1` отдаётся безусловно; frontend fail-closed gate
подтверждён (`houseplan-card.ts:4300-4306`, `_haDecorAssetsApi` сверяется с
`DECOR_ASSETS_API_VERSION` перед показом кнопки/upload).
- **AC13** (i18n паритет) — подтверждено программно: ключи en/ru/de/fr
идентичны построчно (сверка множеств ключей, разницы нет).
- **AC14** (bounded batched resolve) — батчинг/дедуп на вызов подтверждён;
явно заявленный negative-cache отсутствует, см. Finding 4.
- **AC15** (гейты зелёные, targeted smoke до ревью) — typecheck/test/build
зелёные лично переподтверждены; targeted-смоки, названные автором, зелёные;
smoke_decor.mjs красный — см. Finding 2 (это не входит в AC15 буквально,
так как AC15 требует только «targeted image smoke», но красный существующий
smoke — самостоятельная находка).
- **AC16** (документация/changelog) — подтверждено: `DECOR-EDITOR.md`,
`ARCHITECTURE.md`, `CONFIG-COMPATIBILITY.md`, `USER-GUIDE.md`/`.ru.md`,
`TESTING.md`, `TESTING-DEMO.md` правлены; оба changelog в этом же
`User-Visible: yes` коммите (проверено `git show --format=%b`).
## Чего не проверял
- Полный HA-harness (`tests_backend/test_ha_websocket.py`,
`test_ha_import_export.py`, concurrency/race-тесты, golden HA-фикстуры) — в
песочнице ревьюера нет `homeassistant`/Pillow (по канону AGENTS.md это
Linux CI/WSL-only гейт), запуск дал бы молчаливый skip, а не
доказательство. Разобрано **чтением** утверждений тестов и сверкой с кодом
(см. AC6/AC9/AC11 выше) — не исполнением.
- Полный `demo/smoke_*` набор (214 файлов, 67 прямых совпадений) — прогнал 14
из них (3 названных автором + 11 отобранных по риску), не 67. Оставшиеся ~53
прямых совпадения и 48 слабых связей не прогнаны — непропорционально
объёму задачи; остаются обязанностью пре-бета прогона (AGENTS.md).
Вывод `smoke-select.mjs` приложен выше целиком со списком «прямое совпадение»/
«слабая связь», решение по каждой из непрогнанных строки не расписано
построчно — учтён общий довод (общие символы `_mode`/`_config` дают широкий,
малоинформативный список).
- Performance smoke / large-house 1000-record профиль (AC14 численно) — не
прогонял; это предрелизный гейт, доказательство AC14 принято чтением
батчинг-кода + unit-теста дедупликации.
- `npm run docs:check`/`scripts/check-docs.mjs` (screenshot fingerprint) —
не нашёл отдельного npm-скрипта с таким именем в `package.json`, не
прогонял отдельно; `npm run build`/`bundle:sync` не жаловались на
fingerprint-рассинхрон, но это не то же самое утверждение.
- Точный численный порог AC2 «bounds» на промежуточных `cell_cm`/DPI
комбинациях сверх того, что покрывает `test/decor-assets.test.mjs`, — принял
unit-покрытие как достаточное доказательство, не гонял ручной матрицы.
- Touch/accessibility (AC13 non-i18n часть: 44×44 таргеты, focus trap, aria) —
не тестировал в браузере руками (ручного тестирования в цикле нет по
процессу); принял на основе чтения CSS/aria-атрибутов в diff, не
верифицировал реальным screen-reader/touch-эмулятором.
## Материал раунда
- Ветка на момент вывода: `38205d87a6e65bf670e214050223a9e22a9d2a42`
(сверено `git rev-parse HEAD` непосредственно перед выводом — совпадает).
- База: `origin/dev` = `e438c25ebe5ffbd4214da473d62a305016a3f3df`.
- Предыдущего кодового раунда не было (это r1 code review); раздел
«Закрытие предыдущего раунда» неприменим.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/51-custom-decor-images`, коммит `c4e45aa99c63` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5fe4f2859927979f0fdec07a24b989a67c5ccb38`
```
git log --all --format='%H %T' | grep 5fe4f2859927
```