20 KiB
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, и вернул файлы в исходное состояние:
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— пусто.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-наблюдения, оба не в скоупе для правки сейчас (узость уже осознанно объявлена в ТЗ, раздел «Риски»: «Полноценный парсер тянуть не нужно и нельзя»), фиксирую как известные границы эвристики, не как дефект:
- Гвард статический и ловит только выражение вида
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: гипотетический обход требует осознанного обфусцирования, а не естественного стиля кода в этом проекте. 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 нет. Вердикт — зелёный.