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

16 KiB
Raw Blame History

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, а самостоятельно прочитал код и воспроизвёл вывод.

  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.

Материал раунда

  • Ветка: 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