Files
houseplan-card/docs/reviews/CODE-REVIEW-398-r1.md
T
2026-08-31 01:05:33 +00:00

20 KiB
Raw Blame History

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 нет. Вердикт — зелёный.