# ТЗ #398 — Гвард против отравы `sys.modules` должен ловить свой класс целиком - Issue: https://github.com/Matysh/houseplan-card/issues/398 - Приоритет: P2, infra/tests; полный трек — класс B (тесты и гейт), меняется правило, а не продуктовый код - Ревизия: 3 (2026-08-31) — по SPEC-REVIEW-398-r2 (Medium: статический гвард против самоочищающегося файла) ## Сценарий Разработчик добавляет backend-тест, которому нужен модуль интеграции без поднятого Home Assistant. Пишет привычное «зарегистрирую модуль в `sys.modules`, чтобы относительные импорты резолвились», гоняет тесты — зелено. Через день на `dev` красный CI: 85 тестов харнесса падают с голым `assert False`, а на причину не указывает ничего. Так было дважды: #389 (`scripts/dump-config-schema.py`) и #42 (`tests_backend/test_backend_quality.py`); оба раза диагностика стоила часов. Гвард против этого класса уже есть (#394), но он ловит только одну форму записи — и потому сам по себе даёт ложное спокойствие. ## Что человек увидит до и после Пользователь продукта — ничего. Разработчик: попытка зарегистрировать модуль интеграции в `sys.modules` из тестового файла краснеет сразу и с объяснением, а не через день в чужой job. ## Проблема и контракт `test/backend-test-hygiene.test.mjs:39` ищет **строковый литерал**: ```js const assigns = /sys\.modules\[\s*(['"])custom_components/; ``` а `tests_backend/pure_imports.py:31` пишет через переменную: ```python sys.modules[name] = module ``` и в поле зрения гварда не попадает. Регистрация переживает свой тест — проверено исполнением: после `import conftest; import test_junction_limits` в `sys.modules` остаются ``` custom_components.houseplan.junction_limits custom_components.houseplan.wall_segment_model custom_components.houseplan.coordinate_canonicalization ``` (пустышки `custom_components` и `custom_components.houseplan` ставит conftest — это легитимный условный путь, введённый #394 и сохраняемый). Сегодня отказа нет: в CI пакет поднимает Home Assistant, и модуль, загруженный `load_pure`, совпадает по содержимому с тем, что видит загрузчик HA. Но это удача порядка импортов — ровно тот же аргумент, который #394 уже признал негодным основанием («корректность харнесса держалась на именах файлов»). **Контракт**: гвард ловит **любую** запись в `sys.modules` по ключу, который начинается с `custom_components`, независимо от формы выражения — литерал, переменная, f-строка, конкатенация. Единственное исключение — `tests_backend/conftest.py`, где подмена условна («нет Home Assistant — нечего ломать»), как решено в #394. Форма проверки меняется с регулярного выражения на разбор синтаксиса Python: регулярка догоняет формы записи бесконечно, разбор видит сам факт присваивания в подписку `sys.modules[...]`. Значение ключа при этом может быть неизвестно статически — тогда гвард обязан отказывать (fail-closed): «не могу доказать, что ключ не из `custom_components`» — это отказ, а не пропуск. **Что делать с `load_pure` — один путь: снимать за собой.** Ревизия 1 допускала второй исход (объявить `pure_imports.py` вторым легальным исключением), и ревьюер справедливо показал, что он несовместим с AC4: регистрация тогда остаётся, и требование «после прогона нет лишних ключей» краснеет по построению. Держать в ТЗ путь, ведущий в заведомо красный критерий, нельзя — вариант снят. `load_pure` восстанавливает `sys.modules` к состоянию до вызова (образец — обратимая подмена в `scripts/dump-config-schema.py` после #389). Существенная деталь, выясненная замером: снимать только собственное имя **недостаточно**. Относительные импорты внутри загружаемого модуля подтягивают соседей, и после загрузки `junction_limits` в `sys.modules` остаются ещё `wall_segment_model` и `coordinate_canonicalization`. Снимать нужно всё, что появилось под префиксом `custom_components` за время `exec_module`, — тогда после вызова остаются ровно пустышки conftest. Проверено исполнением, включая повторный вызов подряд. **Исключений в статическом гварде два, и это не возврат снятого пути.** Гвард читает исходники, не исполняя их, и физически не может знать, что `load_pure` возвращает `sys.modules` в исходное состояние сразу после `exec_module`. По букве AC1 он обязан отклонить `pure_imports.py` — но тогда файл, делающий ровно то, чего задача требует, оказывается вне закона. Поэтому исключений в списке два — `tests_backend/conftest.py` (условная подмена, контракт #394) и `tests_backend/pure_imports.py` (запись, снимаемая тем же вызовом). Разница со снятым «путём 2» существенная: там файл исключался ВМЕСТО очистки, здесь — при обязательной очистке, которую держит исполняемый AC4 и отдельный AC8. Статический список отвечает на вопрос «кому разрешено писать в `sys.modules`», исполняемая проверка — на вопрос «осталось ли что-нибудь после»; подменять второе первым и было исходной ошибкой #394. ## Скоуп / не-скоуп **В скоупе**: `test/backend-test-hygiene.test.mjs`, `tests_backend/pure_imports.py`, при необходимости — способ загрузки в `tests_backend/test_junction_limits.py` и `tests_backend/test_validation.py`. **Не в скоупе**: условная подмена в `tests_backend/conftest.py` (контракт #394), правило про `sys.path` (#393), поведение продуктового кода. ## UX Не применимо. ## Модель данных и миграция Не применимо. ## i18n Новых строк нет. ## Критерии приёмки - **AC1**. Файл, записывающий `sys.modules[<переменная>] = …`, где переменная может содержать имя пакета интеграции, отклоняется гвардом. Доказательство: тест-контракт на синтетическом файле-нарушителе. - **AC2**. Формы `sys.modules[f"custom_components.{name}"]`, `sys.modules["custom_" + "components"]` и присваивание через `sys.modules.setdefault(...)`/`update(...)` тоже отклоняются либо приводят к явному отказу «не могу доказать безопасность». Доказательство: по одному контракту на форму. - **AC3**. Список статических исключений состоит ровно из двух поимённых файлов — `tests_backend/conftest.py` и `tests_backend/pure_imports.py`; любой третий краснеет. Доказательство: контрактный тест на список. - **AC8**. Исключение не превращается в дыру: `pure_imports.py` обязан восстанавливать `sys.modules` в `finally`, и это проверяется отдельно от гварда. Доказательство: тест, который убирает восстановление и получает красный AC4 (мутант `pure-imports-stops-cleaning`). - **AC4**. После прогона всего `tests_backend/` в `sys.modules` нет ключей `custom_components*` сверх тех, что положил conftest. Доказательство: исполняемая проверка, а не чтение исходников. - **AC5**. Существующие backend-тесты продолжают проходить: относительные импорты внутри загружаемых модулей (`from .wall_segment_model import …`) резолвятся, `pytest tests_backend/` зелёный. - **AC6**. Правило #393 (`sys.path`) и правило #394 (условность подмены в conftest) не ослаблены: их контракты остаются на месте и продолжают краснеть на своих нарушителях. ## План автотестов **Unit** (`test/backend-test-hygiene.test.mjs`): 1. Синтетический файл с записью через переменную → гвард краснеет (AC1). 2. По контракту на каждую форму из AC2. 3. `conftest.py` со своей условной подменой → зелено (AC3). 4. Попытка добавить третий файл в список исключений → красный (AC3). **Backend** (`tests_backend/test_backend_quality.py` или новый файл): 5. Исполняемая проверка AC4: собрать `sys.modules` до и после прогона набора, разница пуста сверх conftest. **Мутант** (`scripts/mutation-gate.mjs`): - `sysmodules-guard-blind-to-variable`: добавить запись через переменную в файл ВНЕ списка исключений → гвард красный. - `pure-imports-stops-cleaning`: убрать восстановление `sys.modules` из `finally` в `pure_imports.py` → исполняемая проверка AC4 красная (AC8). ## Риски - **Разбор Python из Node.** Полноценный парсер тянуть не нужно и нельзя: достаточно узкого разбора строк вида «присваивание в подписку `sys.modules`» с fail-closed на всё непонятое. Риск — ложные срабатывания на комментариях и строковых литералах; смягчение: тест-контракты на «похожий, но безопасный» код (например, `# sys.modules[...]` в комментарии) должны оставаться зелёными. - **Снятие регистрации ломает относительные импорты.** Восстановление слишком рано уронит `from .x import y` внутри модуля. Смягчение: восстановление строго после `exec_module` (замер: модуль полностью рабочий после очистки), AC5 покрывает оба существующих места использования. - **Соседи, подтянутые относительными импортами.** Снятие одного лишь собственного имени оставляет `wall_segment_model` и `coordinate_canonicalization` — замер это показал. Смягчение: снимать разницу по префиксу `custom_components`, а не одно имя; AC4 проверяет исполнением итог, а не намерение. - **Скрытая зависимость от повторного импорта.** Модуль, загруженный дважды, даёт два объекта классов; тесты, сравнивающие типы, могли на этом молча держаться. Смягчение: AC5 гоняет весь набор, а не отдельный файл. ## Откат Возврат регулярки и прежнего `load_pure` — два коммита назад, продуктовый код не затронут. ## Release-артефакты Пользовательских изменений нет: `User-Visible: no`, changelog не трогается. `docs/ARCHITECTURE.md` — при необходимости одна строка в разделе про гейты бэкенда о том, что гвард разбирает синтаксис, а не текст.