Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
16 KiB
SPEC-REVIEW-428-r1
Issue: #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, а самостоятельно прочитал код и воспроизвёл вывод.
- Первопричина независимо подтверждена чтением кода, не только текстом
issue/ТЗ:
content_manifest()(import_export.py:418-479) — при отсутствии и blob, и.json-sidecarmetadata.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.
- Источник контракта — не выдумка автора. Сверил ссылку на ТЗ #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. Новое ТЗ не придумывает продуктовое поведение, а восстанавливает уже принятый контракт, который код нарушает. - Проверка регрессионной матрицы (AC5/AC6) на реализуемость. Строка
row ["exists_at_export"] = declared.get("exists_at_export")в текущем коде вообще не проверяет тип поля — значит требование AC5/AC6 «строгийtype(x) is bool» — это новая, а не восстанавливаемая проверка; она согласована с разделом «Риски» (0/1как под-классint) и не конфликтует с уже существующими данными: JSONtrue/falseпарсится Python'ом только какbool, так что регресс для валидных прежних экспортов исключён. - Использование
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» ТЗ обоснован, не декларативен. - Проверил соответствие процессу: аналитика правильно называет нарушенный
критерий лёгкого трека; артефакт лежит по правильному пути
docs/specs/428-missing-decor-asset-roundtrip.md;docs/specs/README.mdполучил строку с рабочей ссылкой; коммит85ba7a5fнесётIssue: #428иUser-Visible: no— корректно для docs-only коммита ТЗ. - Сверил обязательные разделы §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.
Материал раунда
- Ветка:
issue/428-missing-decor-asset-roundtrip, коммит85ba7a5f304f— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
413e5407d2d52959155b011df38118f15fc31c0cgit log --all --format='%H %T' | grep 413e5407d2d5 - ТЗ
docs/specs/428-missing-decor-asset-roundtrip.md, блоб9271c0a82f4072229c39c408330cb1dfb9ca4fe2git log --all --find-object=9271c0a82f4072229c39c408330cb1dfb9ca4fe2 -- docs/specs/428-missing-decor-asset-roundtrip.md