Files
houseplan-card/docs/specs/398-sysmodules-guard-scope.md
T
2026-08-31 03:40:36 +03:00

202 lines
14 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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` — при необходимости одна строка в разделе про гейты
бэкенда о том, что гвард разбирает синтаксис, а не текст.