Files
houseplan-card/docs/reviews/SPEC-REVIEW-428-r1.md
T
Codex d4dd027b0a build: prepare v1.71.0-beta.2 candidate
Issue: #426
Issue: #427
Issue: #428
Issue: #431
Issue: #432
Issue: #434
User-Visible: no
2026-09-03 15:23:40 +03:00

189 lines
16 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.
# SPEC-REVIEW-428-r1
Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428) — «Экспорт с
недостающей картинкой декора не импортируется — ImportFailure на весь документ».
Материал: тело issue #428, комментарии (аналитика + автор ТЗ), файл
`docs/specs/428-missing-decor-asset-roundtrip.md` на коммите `85ba7a5f`
(HEAD ветки `issue/428-missing-decor-asset-roundtrip`), исходный контракт
`docs/specs/051-custom-decor-images.md` (AC10/AC11 и раздел «Import/export и
совместимость»), текущий код `custom_components/houseplan/import_export.py`,
существующий тест `tests_backend/test_ha_import_export.py`.
Заход: r1 (первый), правила §2.10 о разборе по дельте не применяются — разбор
полный.
## Скоуп
Полный трек (метка `small` не выставлена; аналитика явно называет нарушенный
критерий §5 — «сложность и риск ≤ 3» не выполнен, изменение затрагивает
fail-closed границу export/import). ТЗ лежит в `docs/specs/`, как и требуется
для не-`small` задачи. Задача — точечное ослабление одной проверки в
`_content_state()`: строка `decor_asset` с `exists_at_export: false` и
`mime: null` должна проходить импорт вместо `ImportFailure("invalid_content")`
на весь документ.
## Как проверялось
Проверка велась состязательно: не поверил на слово авторскому «подтверждено
исполнением» из тела issue, а самостоятельно прочитал код и воспроизвёл вывод.
1. **Первопричина независимо подтверждена чтением кода**, не только текстом
issue/ТЗ:
- `content_manifest()` (`import_export.py:418-479`) — при отсутствии и blob,
и `.json`-sidecar `metadata.get("mime")` пусто, `blob` равен `None`,
`.get(blob.suffix if blob else "")` → `.get("")` → `None`. Значит
`mime: null` в манифесте при `exists_at_export: false` — реальный, не
гипотетический случай.
- `_content_state()` (`import_export.py:1643-1649`) безусловно требует
`declared.get("mime") in {"image/png", "image/jpeg", "image/webp",
"image/svg+xml"}` для *любой* строки `decor_asset`, независимо от
`exists_at_export`. При `mime: null` это всегда `ImportFailure`. Баг
воспроизводится чтением, эквивалентен тому, что показал субагентский
прогон автора.
- Существующий тест `test_issue_51_missing_decor_asset_stays_as_repairable_geometry`
(`tests_backend/test_ha_import_export.py:55-80`) действительно строит
только случай «blob был у источника (`exists_at_export: True`, MIME
известен из sidecar), отсутствует у target» — заявление ТЗ о непокрытом
случае C подтверждено, тест не проверяет `mime: null`.
2. **Источник контракта — не выдумка автора.** Сверил ссылку на ТЗ #51: AC11
(«Import принимает v1/v2, fail-closed проверяет manifest… а после
подтверждения сохраняет missing image placeholder») и раздел «Import/export
и совместимость» (`051-custom-decor-images.md:339-361`) действительно
объявляют missing-at-export легальным восстановимым состоянием с
confirmation + repair-placeholder. Новое ТЗ не придумывает продуктовое
поведение, а восстанавливает уже принятый контракт, который код нарушает.
3. **Проверка регрессионной матрицы (AC5/AC6) на реализуемость.** Строка `row
["exists_at_export"] = declared.get("exists_at_export")` в текущем коде
вообще не проверяет тип поля — значит требование AC5/AC6 «строгий
`type(x) is bool`» — это новая, а не восстанавливаемая проверка;
она согласована с разделом «Риски» (`0`/`1` как под-класс `int`) и не
конфликтует с уже существующими данными: JSON `true/false` парсится
Python'ом только как `bool`, так что регресс для валидных прежних
экспортов исключён.
4. **Использование `mime` вне этой проверки.** Проверил, что декларированный в
манифесте `mime` — не источник истины ни для чего, кроме этой валидации:
`decor_assets.py` определяет и проверяет MIME отдельно, по фактическим
байтам загруженного файла (`_validate_asset`, строки ~312-350), а
`config`-запись decor-объекта вообще не хранит `mime` — только `asset_id`.
Ослабление проверки поля `mime` в манифесте не открывает MIME-confusion:
реальная доступность строки по-прежнему определяется пересчётом SHA-256 по
байтам кандидата на target (`import_export.py:1650-1659`), а не
декларацией источника. Раздел «Безопасность и privacy» ТЗ обоснован, не
декларативен.
5. Проверил соответствие процессу: аналитика правильно называет нарушенный
критерий лёгкого трека; артефакт лежит по правильному пути
`docs/specs/428-missing-decor-asset-roundtrip.md`; `docs/specs/README.md`
получил строку с рабочей ссылкой; коммит `85ba7a5f` несёт `Issue: #428` и
`User-Visible: no` — корректно для docs-only коммита ТЗ.
6. Сверил обязательные разделы §7.1: сценарий, «что человек увидит до/после»
(таблица), проблема («Подтверждённая причина»), скоуп/не-скоуп, контракт
поведения («Контракт manifest и валидации»), совместимость/миграция,
touch/i18n/perf, затронутые файлы, AC1–AC9 с доказательствами, план
автотестов, риски, откат, release-артефакты, принятые предположения — все
присутствуют по содержанию (раздел «UX» не выделен отдельным заголовком, но
его содержание — «нового диалога, текста ошибки или элемента управления
нет» — прямо сказано в тексте; см. находку Low ниже).
## Находки
### Low — формулировка граничного значения `mime: ""` в таблице раздела 2 неполна
Таблица «Допустимые строки при импорте» (раздел «Контракт manifest и
валидации», п.2) описывает ветку `exists_at_export: false` тремя строками:
«поддерживаемая строка» → допустимо; «отсутствует или `null`» → допустимо;
«неподдерживаемая **непустая** строка либо значение другого типа» →
`ImportFailure`. Пустая строка `mime: ""` не входит буквально ни в одну из
трёх формулировок: она не «отсутствует или `null`», но и не «непустая».
Проверил, ломает ли это реализуемость: естественная реализация из кода
(`declared.get("mime") not in SUPPORTED and declared.get("mime") is not
None` при `exists_at_export is False`) отклоняет `""` тем же путём, что и
любую другую неподдерживаемую строку — то есть содержательного разночтения в
поведении нет, реализация детерминирована. Дефект чисто в формулировке
таблицы («непустая» лишнее слово), не в контракте. Снимаю находку как
**Low, не блокирует**: замечание оставлено с записью для точности документа,
править не обязательно, так как план автотестов (п.5, «параметризовать
`exists_at_export` и `mime` по таблице») в любом случае может включить `""`
как один из «unsupported non-null MIME» без противоречия итоговому коду.
## Что проверено и корректно
- Первопричина бага реальна и подтверждена независимо (не только доверием к
тексту автора) — см. «Как проверялось» п.1.
- Контракт-источник (#51 AC11) реален, процитирован точно, новое ТЗ его не
меняет, а восстанавливает.
- Скоуп узкий и не расширяется: не задета `EXPORT_FORMAT_VERSION`,
config/model schema, UI подтверждения, upload/delete/replace, frontend —
всё явно перечислено в «Не-скоуп» и это согласуется с «Затронутые файлы»
(только backend + backend-тесты + доки + changelog).
- Таблица допустимых/недопустимых значений (раздел 2) в остальном
исчерпывающая и корректно закрывает найденный класс уязвимости («risk 1» —
«слишком широкое ослабление manifest») точной формулировкой инвариантов,
которые остаются обязательными (exact `asset_id`/`hash`, identity полей,
повторная проверка target blob по байтам).
- AC1–AC9 пронумерованы, каждый с указанным способом доказательства (backend
/ docs gate / ревью кода / commands + Linux CI), формулировки однозначны,
не пересекаются по ответственности.
- Риски названы предметно (широкое ослабление, `bool`/`int` в Python,
supplied-metadata как authority, helper vs настоящий export/import) и у
каждого явно назван снимающий его механизм в контракте/AC.
- «Принятые предположения» оформлены как предположения, а не факты, и
ревьюер с ними согласен по итогам независимой проверки кода — не
гадание, выданное за решение.
- Откат описан и достаточен (revert коммита, без миграции данных).
- Track/процесс: причина полного трека названа явно (критерий §5 не
выполнен), путь артефактов и трейлер коммита ТЗ соответствуют PROCESS.md.
- Продуктовых вопросов владельцу в ТЗ нет — обоснованно: видимое поведение
уже зафиксировано принятым контрактом #51, разбираемый вопрос был
технический (валидация fail-closed границы) и решён автором, а не вынесен.
## Чего не проверял
- Реализация ещё не написана (стадия ТЗ) — код-ревью, автотесты и прогон
гейтов (`typecheck`/`test`/`build`/backend pytest) не в скоупе этого этапа
и будут выполнены на код-ревью по факту диффа.
- Не проверял golden/smoke/performance — задача не трогает frontend/визуал
(сама ТЗ явно это утверждает и обоснование подтверждено чтением: правки
ограничены `custom_components/houseplan/import_export.py` и бэкенд-тестами).
- Не проверял точный будущий текст правок `docs/USER-GUIDE.md`,
`docs/USER-GUIDE.ru.md`, `docs/CONFIG-COMPATIBILITY.md` и changelog — они
ещё не написаны; AC8 корректно называет их обязательными и привязывает к
тому же `User-Visible: yes` коммиту, этого на этапе ТЗ достаточно.
- Не проверял поведение с `space_id`/single-space export код-путём построчно
за пределами того, что нужно для оценки AC3 (проверил только то, что
`content_manifest()` вызывается на уже спроецированный `config` во всех
трёх режимах, включая `plan_only`, и что `asset_id` не выпадает из
plan-only проекции decor-объекта).
## Вердикт
Зелёный. High: 0. Medium: 0. Единственная находка — Low, снята с запиской
(см. выше), автор ничего чинить не обязан.
## Материал раунда
- SHA материала: `85ba7a5f304f9121815b947c9234c545bfaad65e`
(`origin/issue/428-missing-decor-asset-roundtrip`, идентичен HEAD на момент
ревью).
- Дерево: `docs/specs/428-missing-decor-asset-roundtrip.md`,
`docs/specs/README.md`.
- Поиск при необходимости: `git log --all --format='%H %T' | grep <дерево>`;
`git log --all --find-object=<блоб> -- docs/specs/428-missing-decor-asset-roundtrip.md`.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/428-missing-decor-asset-roundtrip`, коммит `85ba7a5f304f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `413e5407d2d52959155b011df38118f15fc31c0c`
```
git log --all --format='%H %T' | grep 413e5407d2d5
```
- ТЗ `docs/specs/428-missing-decor-asset-roundtrip.md`, блоб `9271c0a82f4072229c39c408330cb1dfb9ca4fe2`
```
git log --all --find-object=9271c0a82f4072229c39c408330cb1dfb9ca4fe2 -- docs/specs/428-missing-decor-asset-roundtrip.md
```