# CODE-REVIEW-398-r1 - Issue: https://github.com/Matysh/houseplan-card/issues/398 - Этап: code (PROCESS.md §2.7) - Заход: r1 (первый заход код-ревью; заходы r1-r3 в комментариях issue относятся к этапу spec и сюда не переносятся) - SHA под ревью: `d9b67663624364b46b6263c66e9e8b34c3021964` (`git log --oneline origin/dev..HEAD`, диапазон коммитов от `69dd09a7` до `d9b67663`) - ТЗ: `docs/specs/398-sysmodules-guard-scope.md`, ревизия 3 (принята зелёным на этапе spec, SPEC-REVIEW-398-r3) ## Скоуп диффа ``` docs/ARCHITECTURE.md | 7 ++ docs/reviews/SPEC-REVIEW-398-r1.md | 183 +++++ docs/reviews/SPEC-REVIEW-398-r2.md | 204 +++++ docs/reviews/SPEC-REVIEW-398-r3.md | 167 +++++ docs/specs/398-sysmodules-guard-scope.md | 201 +++++ scripts/mutation-gate.mjs | 22 ++ test/backend-test-hygiene.test.mjs | 135 ++++- tests_backend/pure_imports.py | 24 ++- tests_backend/test_backend_quality.py | 39 ++ 9 files changed, 975 insertions(+), 7 deletions(-) ``` Продуктовый код (`custom_components/**/*.py`, `src/**`) не тронут — подтверждено `git diff --stat origin/dev...HEAD -- src/` и `-- custom_components/` (пусто). `tests_backend/conftest.py` тоже не тронут — граница «не в скоупе» (контракт #394) держится. Класс изменения — B (тесты и гейт), как заявлено в ТЗ. `User-Visible: no` во всех коммитах, changelog не трогается — корректно для инфраструктурной правки без видимого поведения. ## Как проверялось Ручного тестирования в цикле нет; ниже — то, что прогнано мной и его результат, плюс разбор кода там, где исполнение недоступно или избыточно. ### Дешёвые гейты (прогнаны) - `npx tsc --noEmit` — чисто, без вывода. - `npm test` — **1662 pass, 0 fail, 1 skipped** (1..1641 top-level с подтестами). Совпадает с числом, заявленным автором в комментарии к issue. - `npm run build` — `tsc --noEmit && rollup -c`, бандл собран успешно за 15.6s. Сверка трёх копий бандла и `node scripts/check-docs.mjs` не выполнялись: диф не трогает `src/**`, отпечаток документации не устаревает, а строгий гейт на бандл нужен только когда фронтенд меняется. - `node scripts/smoke-select.mjs --base origin/dev --head HEAD` — вывод: «Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются — это не «пропустить проверки», а «выбирать нечего»: смоки проверяют собранную карточку. Тронуто файлов: 9.» — согласуется с диффом, ни один смок не запускался, и это обосновано инструментом, а не моим решением. ### Backend-тесты (прогнаны частично, целенаправленно) Полный `python -m pytest tests_backend -q` требует `pytest-homeassistant-custom-component` и Python 3.14 (см. `tests_backend/requirements.txt`) — тяжёлый стек, не предустановленный в этой песочнице (только Python 3.12). Инструкция гейтит этот прогон на изменения `custom_components/**/*.py`, которых в этом диффе нет. Вместо холостого пропуска я поставил лёгкие зависимости (`pytest`, `voluptuous==0.15.2` — оба чистый Python, без HA) и прогнал всё, что не требует реального Home Assistant: - `python3 -m pytest tests_backend/test_backend_quality.py -q` — **5 passed** (включая новый `test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it`, AC4). - `python3 -m pytest tests_backend/test_junction_limits.py -q` — **16 passed** (единственный файл, явно перечисленный в «в скоупе» ТЗ как потребитель канонического имени через `load_pure`). - `python3 -m pytest tests_backend/test_validation.py -q` — **142 passed, 1 skipped** (использует и `pure_imports.load_pure`, и собственный `_load_pure` с `hp_pure.*` — оба паттерна, которые гвард обязан не путать с нарушением). - `python3 -m pytest tests_backend -q --ignore=tests_backend/test_coordinate_canonicalization.py` — **246 passed, 1 skipped**, без ошибок сборки sys.modules между файлами. Единственный проигнорированный файл падает при коллекции с `ModuleNotFoundError: No module named 'homeassistant'` — он импортирует `custom_components.houseplan.store`, который тянет `homeassistant.config_entries` напрямую, в обход `load_pure`; это давно существующая зависимость от полного HA-стека, дифф её не касается и не может починить. Это закрывает AC5 («существующие backend-тесты продолжают проходить») по факту исполнения почти всего набора, кроме той единственной части, что физически требует Home Assistant и не относится к предмету правки. ### Мутанты (оба воспроизведены вручную, не через `scripts/mutation-gate.mjs`) Полный `scripts/mutation-gate.mjs` пересобирает бандл в отдельном worktree для каждого мутанта — дорогая операция уровня предрелизного гейта (файл сам об этом говорит: «прогон дорогой... его место — перед стабильным релизом»). Для двух новых мутантов пересборка бандла не нужна (`guard` — `node --test` одного файла и `pytest` одного файла), поэтому я применил патчи руками, не трогая инфраструктуру mutation-gate, и вернул файлы в исходное состояние: 1. **`sysmodules-guard-blind-to-variable`**. Патч `test/backend-test-hygiene.test.mjs`: строка `if (start === null) { if (namesThePackage) hits.push(match[0]); continue; }` → `if (start === null) { continue; }` (снят fail-closed на нелитеральный ключ). Прогон `node --test test/backend-test-hygiene.test.mjs` — **`# fail 2`** (падают `#398 AC1` и часть `#398 AC2`). Файл возвращён в исходное состояние, `git status --porcelain` — пусто. 2. **`pure-imports-stops-cleaning`**. Патч `tests_backend/pure_imports.py`: `del sys.modules[key]` → `pass` (снята очистка). Прогон `python3 -m pytest tests_backend/test_backend_quality.py -q -k issue_398` — **красный**: ``` AssertionError: load_pure оставил в sys.modules: ['custom_components.houseplan.coordinate_canonicalization', 'custom_components.houseplan.junction_limits', 'custom_components.houseplan.wall_segment_model'] ``` Файл возвращён в исходное состояние, `git status --porcelain` — пусто. Оба мутанта, зарегистрированные в `scripts/mutation-gate.mjs`, действительно краснеют на названном ими `guard` — дисциплина «тест умеет падать» выполнена для обоих, силами ручного воспроизведения вместо дорогого прогона реестра. ### Не проверялось (и почему) - **`python -m pytest tests_backend -q` с полным HA-стеком** — не установлен в песочнице (Python 3.14 + `pytest-homeassistant-custom-component` + `home-assistant-frontend`, сотни МБ). Диф не трогает `custom_components/**/*.py`, поэтому по инструкции гейт не обязателен; компенсировано прогоном 246/247 тестов без HA (см. выше) — не покрыт только один файл, требующий HA по независимой от диффа причине. - **`ruff`/`mypy` бэкенда** — область этих гейтов в CI ровно `custom_components/houseplan` (`validate.yml:788`), диф её не касается. - **`npm run golden:verify`** — диф не меняет рендер, геометрию, стили, слои. - **`npm run invariants`** — диф не трогает рёбра комнат, `layout`, `marker.space`, `open_spans` или иные ссылки на геометрию; модель данных не затронута (раздел ТЗ «Модель данных и миграция»: не применимо). - **`node scripts/check-docs.mjs`** — диф не трогает `src/**`. - **`scripts/mutation-gate.mjs` целиком** (пересборка бандла в worktree) — избыточно для инфраструктурного диффа без изменений в `src/**`; оба новых мутанта проверены вручную (см. выше), остальные 60+ мутантов не связаны с этим диффом. - **«Одно число — один источник»** — неприменимо: диф не добавляет и не меняет ни одной пользовательской величины. ## Разбор по AC - **AC1** (запись через переменную ловится). Доказано контрактным тестом `#398 AC1` (`test/backend-test-hygiene.test.mjs:127`) и подтверждено мутантом `sysmodules-guard-blind-to-variable` — красный при снятии проверки. ✅ - **AC2** (остальные формы: f-строка, конкатенация, `setdefault`/`update`, пробелы вокруг ключа — ловятся; безопасные формы не ловятся). Доказано двумя тестами `#398 AC2` (строки 132 и 144). Прочитан код `sysModulesWrites`: разбор ключа через `literalStart` корректно отличает целый литерал и f-строку с литеральным началом от выражения; `namesThePackage` — грубый, но верно направленный fail-closed триггер («файл вообще способен назвать пакет»). Тестовые случаи безопасных строк (`hp_pure.*`, `houseplan.trails`, закомментированная строка) взяты дословно из реальных файлов (`test_validation.py:103`, `test_junction_limits.py`) — не выдуманы, а списаны с кода, который гвард обязан не сломать. ✅ - **AC3** (список исключений закрыт двумя именами). Доказано тестом `#398 AC3` через `assert.deepEqual` — третий элемент сломает и этот тест, и `#398 AC1`-тесты на любом файле вне списка. Прочитан код: `SYS_MODULES_WRITERS` используется как фильтр в тесте про #394, список действительно `['conftest.py', 'pure_imports.py']`. ✅ - **AC8** (`pure_imports.py` восстанавливает `sys.modules` в `finally`, доказано отдельно от гварда). Доказано мутантом `pure-imports-stops-cleaning` → красный `test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it` (воспроизведено выше). Статический тест `#398 AC8` (строка 164) дополнительно проверяет форму кода (`finally`, `del sys.modules[key]`, `key.startswith(PACKAGE_ROOT.name)`) — это проверка «код написан так, как обещано», а не замена исполняемой проверки; обе присутствуют, как и требовало разделение ролей из ТЗ. ✅ - **AC4** (после прогона `tests_backend/` в `sys.modules` нет лишних ключей `custom_components*`). Доказано исполняемым тестом `test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it`, который сравнивает разницу до/после, включая повторный вызов подряд — прогнан лично, зелёный (см. «Backend-тесты» выше). ✅ - **AC5** (существующие backend-тесты проходят, относительные импорты резолвятся). Прогнано 246/247 тестов `tests_backend/` без HA — все зелёные; единственный непрогнанный файл падает по причине, не связанной с диффом (прямой импорт `homeassistant.config_entries`, минуя `load_pure`). ✅ - **AC6** (#393 и #394 не ослаблены). Тест `sys.path` (#393) не изменён дифом. Тест #394 переписан на `sysModulesWrites`, но сохраняет обе проверки: список офендеров пуст и подмена в conftest — под `if not HAS_HA:`. Прогнан как часть `npm test`, зелёный. ✅ Ни одного AC, помеченного «проверено чтением, не исполнением» без одновременного независимого прогона, в этой задаче нет — каждый AC либо подтверждён исполняемым тестом лично, либо мутантом, либо обоими. ## Находки Блокирующих (High/Medium) находок нет. Два Low-наблюдения, оба не в скоупе для правки сейчас (узость уже осознанно объявлена в ТЗ, раздел «Риски»: «Полноценный парсер тянуть не нужно и нельзя»), фиксирую как известные границы эвристики, не как дефект: 1. **Гвард статический и ловит только выражение вида `sys.modules[...] =` / `sys.modules.setdefault|update(...)`, но не алиасинг**. Код вида `sm = sys.modules; sm["custom_components.x"] = mod` или `import sys as s; s.modules[...] = ...` не совпадёт ни с одним из regex в `sysModulesWrites` (оба привязаны к литеральной подстроке `sys.modules`) и пройдёт мимо гварда, даже если файл называет пакет интеграции литералом. Не воспроизведено как реальный дефект — сегодня в `tests_backend/` такого кода нет (проверено чтением всех `.py` файлов), и AC2 явно перечисляет закрываемые формы (f-строка, конкатенация, `setdefault`/`update`), алиасинг в их числе не назван. Ниже порога Medium: гипотетический обход требует осознанного обфусцирования, а не естественного стиля кода в этом проекте. 2. **`namesThePackage` требует точку после `custom_components`** (regex `(['"])custom_components\./`). Файл, который литералом называет только голое `"custom_components"` (без точки) где-то не рядом с записью, и пишет в `sys.modules` через переменную в другом месте, не взведёт fail-closed триггер для этой записи (хотя прямая запись `sys.modules["custom_components"] = ...` всё равно поймана — это отдельная ветка `literalStart`, точки не требует). Крайне узкий кейс, не воспроизведён в реальном коде. Оба наблюдения не требуют действия в этой задаче: серьёзность ниже Medium, эксплуатируются только гипотетическим кодом, которого сегодня в репозитории нет, а сама спецификация прямо признаёт эвристику неполной и заранее ограничивает её притязания («узкий разбор... а не полноценный парсер»). ## Что проверено и корректно - Разбор ключа (`literalStart`) отличает литерал/f-строку-с-литеральным-началом от выражения; протестирован на реальных строках из `test_validation.py` и `test_junction_limits.py`, а не на синтетике, оторванной от кода проекта. - `load_pure` восстанавливает `sys.modules` по всей разнице под префиксом `custom_components`, а не только по собственному имени — воспроизведено исполнением (мутант выше явно показывает три «оставленных» ключа, включая соседей по относительным импортам). - `load_pure` не трогает нестандартные регистрации (`hp_validation`, `hp_pure.*`) — они не подпадают под префикс `custom_components` и сознательно остаются вне скоупа AC4 (класс дефекта #389/#394/#398 — это именно коллизия с загрузчиком HA по имени пакета интеграции, а не любая утечка в `sys.modules`). - Список исключений и его обоснование (`conftest.py` — условная подмена, `pure_imports.py` — самоочистка) закрыт тестом на список, а не только комментарием. - `docs/ARCHITECTURE.md` дополнен одной точной фразой, соответствующей коду (проверено построчно), без раздувания документа. - Трейлеры `Issue: #398` и `User-Visible: no` — в каждом коммите диапазона; changelog не требуется и не тронут — правильно для этого класса изменения. - Скоуп/не-скоуп ТЗ выдержан: `tests_backend/conftest.py`, `custom_components/**`, `src/**` не задеты. ## Вывод Все критерии приёмки (AC1–AC6, AC8) доказаны исполняемым способом там, где это требовало ТЗ, и подтверждены мной лично, а не только заявлены автором. Дешёвые гейты зелёные, оба мутанта проверены на способность падать вручную. Находок уровня Medium/High нет. Вердикт — зелёный.