mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
202 lines
14 KiB
Markdown
Executable File
202 lines
14 KiB
Markdown
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` ищет **строковый литерал**:
|
||
|
||
```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` — при необходимости одна строка в разделе про гейты
|
||
бэкенда о том, что гвард разбирает синтаксис, а не текст.
|