mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
244 lines
27 KiB
Markdown
244 lines
27 KiB
Markdown
# 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
|
||
```
|