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

244 lines
27 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» · заход r2
- Issue: https://github.com/Matysh/houseplan-card/issues/51
- Материал раунда: `git diff 38205d87a6e65bf670e214050223a9e22a9d2a42..HEAD` (материал
предыдущего раунда объявлен документом `docs/reviews/CODE-REVIEW-51-r1.md`,
раздел «Материал раунда» той же ветки).
- SHA материала: `042af520fe4f6cc47d78ae6ccbb105580382e961` (сверено с
`git rev-parse HEAD` непосредственно перед выводом — совпадает).
- SHA предыдущего раунда (r1): `38205d87a6e65bf670e214050223a9e22a9d2a42` — жив
(не осиротел), `git cat-file -t` подтверждает.
- Трейлеры фикс-коммита `042af520`: `Issue: #51`, `User-Visible: no` (правки
не создают нового пользовательского поведения сверх того, что уже
задокументировано для feature-коммита `38205d87`; см. «Один вопрос про
User-Visible» ниже).
## Скоуп раунда
Автор ответил на все содержательные находки CODE-REVIEW-51-r1 (High:1,
Medium:3, Low:2) одним коммитом `042af520` поверх `38205d87`. Дельта: 29
файлов, +831/-307 (без учёта переносимого `docs/reviews/CODE-REVIEW-51-r1.md`
и сгенерированных бандлов — предметных 8 файлов исходников/тестов). Разбор
этого раунда — **по дельте**, не по задаче целиком: ребейза на ушедший вперёд
`dev` не было (`38205d87` остаётся прямым предком `HEAD`), контракт поведения
не сменился, новая подсистема не задета. Объём дельты (8 предметных файлов, ни
одного нового модуля) заметно меньше исходной задачи (63 файла) — критерий
«разбор остаётся полным» не выполняется, сокращение объёма правомерно.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **High** — DTD/entity-фильтр обходится UTF-16 кодировкой SVG, entity bomb достигает `ET.fromstring` в обход собственной защиты | Байтовый префильтр заменён на encoding-aware `expat.ParserCreate()` с хендлерами `StartDoctypeDeclHandler`/`EntityDeclHandler`/`ExternalEntityRefHandler`/`ProcessingInstructionHandler`, которые фейлят закрыто **до** любого построения дерева; вызывается раньше `ET.fromstring` | `custom_components/houseplan/decor_assets.py:162-197` (`_reject_svg_declarations`); регрессионный тест `tests_backend/test_decor_assets.py::test_svg_utf16_doctype_is_rejected_before_entity_expansion` — **воспроизведено лично**: тест красный на `38205d87` (`DID NOT RAISE`, отдельный worktree), зелёный на `HEAD`; дополнительно я сам скормил `_reject_svg_declarations`/`validate_asset` UTF-32/UTF-32LE/UTF-32BE-кодированные billion-laughs, вариант без XML-декларации и parameter-entity/XXE-пейлоад (`SYSTEM "file:///etc/passwd"`) — все отклонены (`DTD, entities...` либо `The SVG is not valid XML`), обхода не нашёл |
| **Medium (в скоупе)** — `demo/smoke_decor.mjs` вызывает убранный делегатор `c._furnPlace` напрямую | Вызов переведён на `c._editorRuntime._furnPlace(...)`, тот же паттерн, что уже применён к `smoke_furniture.mjs` в r1 | `demo/smoke_decor.mjs:192`; **воспроизведено лично**: `node demo/smoke_decor.mjs` зелёный на `HEAD` (прогнал сам, свежая сборка) |
| **Medium (в скоупе)** — AC5 «одна projection-функция» не буквальна: дублирование формулы между `houseplan-card.ts` и `space-render.ts` | Вынесена единая `projectDecorImage(shape, w, h)` в `src/decor-assets.ts`, оба сайта её вызывают; при этом static-рендерер заодно перешёл на `normalizeAngle` (было `Number(shape.angle)\|\|0` — визуально эквивалентно, r1 сам это установил) | `src/decor-assets.ts:35-51`; `src/houseplan-card.ts:8762-8765`; `src/space-render.ts:641-644`; unit `test/decor-assets.test.mjs` сверяет точные числа (angle 405→45, flip, opacity clamp) и fail-closed `null` при `w=0` — прогнал сам, зелёный |
| **Medium (в скоупе)** — нет negative/positive-кэша resolve по `asset_id`, каждая нерелевантная правка конфига переспрашивает `houseplan/assets/resolve` | `WeakMap`-кэш в `resolveDecorAssets`, ключ — стабильный `hass.connection` (или сам `hass`) плюс полный отсортированный набор id; повторный вызов с тем же набором возвращает тот же `Map` без нового `callWS` | `src/decor-assets.ts:23,79-90`; unit `test/decor-assets.test.mjs` («missing asset ids are negative-cached...», `calls === 1` после второго вызова) — прогнал сам, зелёный; smoke `smoke_decor_images.mjs` (`staticResolveCachesCompleteAssetSet`) подтверждает на уровне `houseplan-space-card._load` — прогнал сам, `true` |
| **Low** — тач-committed размещение картинки использовало mouse-допуск снапа | `_decorImagePlace` теперь принимает `pointerType` и форвардит его в `_decorSnap`; оба вызывающих места (`_decorPointerDown`, `_furnPointerUp`) прокидывают реальный `pointerType` | `src/houseplan-editor-runtime.ts:4203,5045,5130-5134`; smoke `smoke_decor_images.mjs` (`touchCommitUsesTouchSnapTolerance`) — прогнал сам, `true` |
| **Low, информационно** — SVG numeric/string bounds отдельных атрибутов не проверялись | Добавлен `MAX_SVG_ATTR_VALUE_CHARS=65536` (лимит на одно значение атрибута) и `_validate_svg_unit_interval` для `opacity`/`fill-opacity`/`stroke-opacity`/`stop-opacity`/`offset` (конечность и диапазон 0..1 либо 0..100%) | `custom_components/houseplan/decor_assets.py:38,54-56,170-181,234-244`; тесты `test_svg_rejects_non_finite_or_out_of_range_unit_values`, `test_svg_rejects_one_oversized_attribute_before_tree_use` — прогнал сам, зелёные (и красные на `38205d87`, см. «Как проверялось») |
| **Low, информационно** — `javascript:`/`data:`-substring проверка тривиально обходима непечатным символом | **Не тронуто.** Оставляю как было принято в r1: не эксплуатируется при текущем allowlist (`href` — строгий `^#id`, единственный URL-приёмник), не архитектурный риск. **Снимаю повторно с той же записью**, фикса не требую | `custom_components/houseplan/decor_assets.py:246-248` (без изменений в дельте) |
Все шесть находок r1, требовавшие правки, закрыты предметно, не декларативно —
по каждой я либо лично проиграл регрессию (High, Medium×2 через unit/smoke),
либо прочитал изменённый код и совпадающий новый тест (оставшиеся). Седьмая
(Low-информационная про URL-substring) сознательно оставлена как есть, с тем
же обоснованием, что и в r1 — вторичной находкой её не завожу.
## Унаследовано из r1
Без повторной проверки приняты как есть — материал `docs/reviews/CODE-REVIEW-51-r1.md`
на SHA `38205d87a6e65bf670e214050223a9e22a9d2a42`, поскольку дельта их не
задевает (нет изменений соответствующих файлов между `38205d87` и `HEAD`):
- **AC1** (кнопка/palette/one-shot), **AC2** (100 см/aspect/cap 200),
**AC4** (properties/replace/no-delete-on-replace), **AC6** (raster/SVG
сигнатуры, атомарность аплоада), **AC8** (content-addressed id, dedup),
**AC9** (server-side refcount, in_use), **AC10** (missing/repair
placeholder), **AC11** (export v2/import v1 lifecycle), **AC12** (rolling
compatibility gate), **AC13** (i18n паритет) — все проверены r1 чтением
и/или тестами, дельта r2 их кода не меняет.
- Golden-сцены (`npm run golden:verify`, 153/153 в r1) — не перепрогонял:
дельта не меняет визуальные числа (см. AC5 в таблице выше — новая
`projectDecorImage` даёт **побитово те же** x/y/w/h/opacity/transform
для валидных входов, что и было до вынесения в функцию; unit-тест сверяет
точные значения). Полный HA-harness (`test_ha_websocket.py`,
`test_ha_import_export.py`) — не перепрогонял по той же причине, что в r1
(нет `homeassistant`/Pillow в песочнице ревьюера); дельта их не касается
(`decor_assets.py` дельта — только `_validate_svg`/`_reject_svg_declarations`
внутренние правки, вызывающий контракт не изменился).
- Bundle budget (291041 B / 300000 B, запас 8959 Б < порога 15000 Б, известный
долг #367) — переподтверждено в этом раунде (см. «Как проверялось»), число
практически не изменилось (было 291046 Б на предыдущей сборке автора).
## Как проверялось (гейты этого раунда)
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный |
| Unit (frontend) | `npm test` | 1778 tests, 1777 passed, 1 skipped, 0 failed |
| Build | `npm run build` | зелёный |
| Bundle sync (3 копии) | `npm run bundle:sync` | зелёный; `git status` после — чисто (dist / `custom_components/.../frontend` / build совпадают побайтово; `demo/srv/assets` не коммитится, #255 — сверено `cmp`) |
| Bundle budget | `npm run bundle:budget` | initial View 291041 B / 300000 B, запас 8959 Б — прошёл, тот же известный долг #367 |
| `no-new-any` по дельте | `node scripts/no-new-any.mjs --base 38205d87 --head HEAD` | зелёный: 51 добавленная строка в 5 файлах, новых `any` нет |
| Backend pure (целевой) | `python3 -m pytest tests_backend/test_decor_assets.py -q` | 27 passed |
| **Backend pure (полный набор, без HA-only модулей)** | `python3 -m pytest tests_backend -q --ignore=test_ha_websocket.py --ignore=test_ha_import_export.py --ignore=test_coordinate_canonicalization.py` (последний игнор — падает по отсутствию `homeassistant` уже при коллекции, не тема этой задачи) | **1 failed, 292 passed** — см. Finding 1 (новая, ниже) |
| Docs fingerprint (обязателен: дельта трогает `src/**`) | `node scripts/check-docs.mjs` | **ERROR: screenshot source fingerprint is stale** — см. Finding 1 (новая, ниже) |
| Model invariants | не прогонял | дельта не трогает `marker.space`, `open_spans`, thickness-записи, `layout` — сверено `git diff 38205d87..HEAD -- src custom_components/houseplan/*.py \| grep -E "marker\\.space\|open_spans\|layout\|thickness"`: единственное совпадение — неизменённая строка контекста (`layoutChanged` continuity-note). Инварианты нерелевантны, не пропущены |
| Целевые смоки (названные автором) | `node demo/smoke_decor_images.mjs`, `smoke_decor.mjs`, `smoke_decor_layer_order.mjs`, `smoke_furniture.mjs` | все 4 — **OK**, прогнаны лично на свежей сборке |
| Выбор дополнительных смоков по дельте | `node scripts/smoke-select.mjs --base 38205d87 --head HEAD` | 214 смоков в матрице, изменено 5 файлов `src/**`, 13 символов на изменённых строках, порог «широкого» — 42. **14 прямых совпадений**: только `smoke_decor_images.mjs` и `smoke_decor.mjs` привязаны к специфичным для дельты символам (`_decorImagePlace`, `_decorSnap`) — оба уже прогнаны выше. Остальные 12 (`smoke_drag_bounds`, `smoke_grid_snap`, `smoke_active_chain_ink`, `smoke_card_tool_conflict`, `smoke_edit_walk`, `smoke_hide_layers`, `smoke_infinite_canvas`, `smoke_island_rooms`, `smoke_junction_holes`, `smoke_junction_limits`, `smoke_partition_openings`, `smoke_wallthick_standalone`) совпали только по `NORM_W`/`_svgPoint` — распространённые символы, слабая связь (аналог `_mode`/`_config` из r1); не прогонял — непропорционально узкой дельте |
| Golden / HA full harness / performance | не прогонял в этом раунде | см. «Унаследовано из r1» — дельта не меняет визуальные числа и не касается вызывающего контракта HA-специфичных тестов; полный набор остаётся предрелизной обязанностью |
Регрессионная дисциплина «тест умеет падать» проверена мной лично, не только
по словам автора: воспроизвёл красный прогон новых тестов на предыдущем SHA —
`git worktree add --detach <tmp> 38205d87`, скопировал новый
`tests_backend/test_decor_assets.py` поверх старого `decor_assets.py` →
**6 упавших** (`test_svg_utf16_doctype_is_rejected_before_entity_expansion`,
4× `test_svg_rejects_non_finite_or_out_of_range_unit_values`,
`test_svg_rejects_one_oversized_attribute_before_tree_use`), на `HEAD` те же
27 тестов зелёные. Для demo-смоков — `smoke_decor.mjs` уже официально
воспроизведён в r1 (красный на `38205d87`, зелёный на фикс-коммите); здесь
переподтверждаю зелёный на актуальном `HEAD`.
Отдельно проверил направление фикса High-находки на предмет обхода: скормил
`_reject_svg_declarations`/`validate_asset` тем же billion-laughs payload'ом,
закодированным в UTF-32 (LE/BE/BOM), вариант без XML-декларации и
parameter-entity/XXE (`<!ENTITY % pe SYSTEM "file:///etc/passwd">`) —
все шесть вариантов отклонены (`DTD, entities...` либо невалидный XML),
самостоятельного обхода не нашёл.
## Находки
### Finding 1 (Medium, в скоупе) — манифест `scripts/config-schema.json` устарел; пере-прогон полного `tests_backend` красный
**Файлы:** `custom_components/houseplan/validation.py` (добавлен decor kind
`image`, часть исходного `38205d87`, не дельты r2), `scripts/config-schema.json`
(не обновлён ни в `38205d87`, ни в `042af520`).
`python -m pytest tests_backend -q` — стандартный гейт код-ревью «если менялся
бэкенд» (PROCESS.md §8) — **красный**:
`tests_backend/test_config_schema_manifest.py::test_issue_33_manifest_is_fresh_and_deterministic`
падает с `AssertionError: scripts/config-schema.json is stale — the schema
changed; run python3 scripts/dump-config-schema.py and commit the diff`.
Ни автор (хендофф-комментарии обоих раундов гоняли только
`test_decor_assets.py -q`), ни ревью r1 (тоже ограничилось
`test_decor_assets.py`, что явно записано в его «Гейты») этот файл не
запускали — находка не относится к дельте r2 буквально (сама схема не
менялась между `38205d87` и `HEAD`), но я обязан был прогнать полный пере-набор
по правилу «backend тронут → `pytest tests_backend`», и он красный **прямо
сейчас**, на актуальном `HEAD` этого раунда.
**Воспроизведено лично, включая причину и фикс:**
- на `origin/dev` (`e438c25e`, отдельный worktree) тот же тест **зелёный**
(4 passed) — устаревание манифеста началось именно с фичи #51;
- на `38205d87` (материал r1, отдельный worktree) тест уже **красный**
(1 failed, r1 просто не заметил, так как не гонял этот файл);
- `python3 scripts/dump-config-schema.py` (без HA/Pillow, только
`voluptuous`, установленный в песочнице) добавляет **76 новых строк**
в манифест — ровно поля новой decor-разновидности
`config.spaces[].decor[]<image>.{asset_id,angle,color,flip_h,flip_v,h,w,x,y,
opacity,id,kind,width,width_cm}` (наследуются от `_DECOR_COMMON` плюс новые
`asset_id`/`flip_h`/`flip_v`); после регенерации
`test_config_schema_manifest.py` зелёный (4 passed). Файл
`scripts/config-schema.json` **восстановлен** мной обратно (`git checkout --`)
сразу после проверки — рабочее дерево ревью чисто, коммитить фикс не мне.
Частичное смягчение: JS-паритет (`test/config-schema-parity.test.mjs`, все 3
подтеста) остаётся зелёным, потому что новая запись реестра
`scripts/config-field-registry.mjs` (`spaces[].decor[]<kind='image'>`) адресует
уже существующий в манифесте обобщённый путь `decor[]` через
дискриминирующий селектор, а не требует нового пути буквально — поэтому баг не
пойман фронтенд-паритетом, только python-снимком (#33 contract).
Не архитектурный риск и не влияет на рантайм пользователя — это
контрактный снимок для #33 (детерминированность схемы для будущих
потребителей манифеста). Но это реальный красный узел стандартного гейта
код-ревью, введённый именно этой задачей, и если он уедет в `dev` как есть —
это тот же класс цены, что у #230/#234/#237 («красный job до следующей
задачи»), только для `tests_backend` вместо `docs`.
**Фикс механический, в скоупе задачи, чинится тем же коммитом:**
`python3 scripts/dump-config-schema.py && git add scripts/config-schema.json`,
без изменения продуктового кода.
## Проверено и корректно
- Все шесть предметных находок r1 закрыты кодом и тестом, не только словом
автора (таблица «Закрытие раунда r1» выше); High полностью снят, обхода
фикса адверсариально не нашёл (UTF-32, no-declaration, parameter-entity/XXE).
- `projectDecorImage` — единственная точка формулы проекции для full View и
`houseplan-space-card`, устраняет класс «одно число — два источника»
(#233/#234): сверено побитово через unit-тест с конкретными числами
(не тавтология).
- Negative/positive resolve-кэш не меняет наблюдаемое поведение для
пользователя (та же итоговая карта ассетов), только убирает лишние WS-вызовы;
единственный найденный мной остаточный (не блокирующий) кейс — если один и
тот же уже закэшированный как «отсутствующий» `asset_id` внезапно появится на
сервере (переливка того же контента) без изменения набора id, шаг
`resolveDecorAssets` с тем же ключом вернёт закэшированный (устаревший)
результат до следующего изменения набора id или перезагрузки страницы.
Практически недостижимо: контент-адресация делает такой сценарий
«удалили → залили обратно тот же файл» единственным триггером, а
server-side refcount (AC9) не доверяет клиентскому кэшу при удалении — то
есть безопасность не страдает, только витрина может на короткое время
отставать. **Low, снимаю с записью**, фикс не требую.
- Touch-committed размещение и SVG per-attribute границы — фикс подтверждён
и кодом, и тестом (unit + smoke), поведение соответствует заявленному в r1
направлению.
- Три копии бандла синхронны, бюджет не превышен (тот же известный долг #367).
## Чего не проверял
- Полный HA-harness (`test_ha_websocket.py`, `test_ha_import_export.py`) —
как и в r1, нет `homeassistant`/Pillow в песочнице; дельта r2 не меняет
вызывающий контракт этих модулей (только внутренности `_validate_svg`).
- Golden (`npm run golden:verify`) и performance-профиль — не перепрогонял;
дельта не меняет визуальные числа (см. «Унаследовано из r1»), полный набор
остаётся предрелизным.
- 12 из 14 «прямых совпадений» `smoke-select` — не прогонял, слабая связь по
распространённым символам (`NORM_W`, `_svgPoint`), не специфичным для
дельты; решение по каждой строке см. таблицу «Как проверялось».
- Точный количественный эффект resolve-кэша на числе живых WS-вызовов в
большом плане (AC14, 1000 записей) — принял unit-тест (`calls === 1` после
повторного вызова) и smoke (`staticResolveCachesCompleteAssetSet`) как
достаточное доказательство контракта, не гонял отдельный
performance-профиль.
- Не проверял, ломает ли отсутствие регенерации `config-schema.json` что-либо
за пределами самого `test_config_schema_manifest.py` (например, внешние
потребители манифеста вне репозитория) — вне доступного мне материала.
## Один вопрос про `User-Visible`
Коммит `042af520` помечен `User-Visible: no`. Формально верно для
security/perf/consolidation-правок (SVG-валидатор, кэш, дедуп formula) — они
не меняют то, что видит пользователь. Но правка touch-снапа (Finding 5 из r1)
меняет реально ощутимое поведение при размещении картинки пальцем/пером
(допуск снапа расширяется до тач-радиуса). Поскольку вся фича #51 ещё не
проходила через бету (issue остаётся в `S6`/`S7`, ни разу не публиковался с
этим багом), пользователь никогда не видел прежнего (более тесного) допуска
на релизе — упоминать это отдельной строкой changelog не требуется; исходная
запись фичи в `docs/CHANGELOG.md`/`.ru.md` (добавленная в `38205d87`) уже
покрывает завершённое поведение целиком. Не находка, оставляю как
рассуждение для протокола.
## Вердикт
Единственная блокирующая (High) находка r1 закрыта и лично проверена
адверсариально. Все Medium/Low из r1 закрыты предметно. Новая находка этого
раунда — Medium в скоупе, механический фикс (`scripts/config-schema.json`
регенерация), без архитектурного риска и без High. По правилу §2.7 это
жёлтый вердикт: возврат автору, фикс проходит следующий цикл ревью.
Вердикт: жёлтый · заход r2 · блокирующих циклов 2/4 · High: 0 · Medium: 1 → в задаче
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/51-custom-decor-images`, коммит `042af520fe4f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `460c8f2339fbae285d0e53cb6dbb177c935ce573`
```
git log --all --format='%H %T' | grep 460c8f2339fb
```