14 KiB
Executable File
ТЗ #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 ищет строковый литерал:
const assigns = /sys\.modules\[\s*(['"])custom_components/;
а tests_backend/pure_imports.py:31 пишет через переменную:
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):
- Синтетический файл с записью через переменную → гвард краснеет (AC1).
- По контракту на каждую форму из AC2.
conftest.pyсо своей условной подменой → зелено (AC3).- Попытка добавить третий файл в список исключений → красный (AC3).
Backend (tests_backend/test_backend_quality.py или новый файл):
- Исполняемая проверка 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 — при необходимости одна строка в разделе про гейты
бэкенда о том, что гвард разбирает синтаксис, а не текст.