docs: review document for #398

Issue: #398
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-31 01:05:33 +00:00
parent d9b6766362
commit f7fb3369b7
+248
View File
@@ -0,0 +1,248 @@
# 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 нет. Вердикт — зелёный.