# SPEC-REVIEW-398-r2 - Issue: https://github.com/Matysh/houseplan-card/issues/398 - Этап: ТЗ на ревью (PROCESS.md §2.4) - Материал: `docs/specs/398-sysmodules-guard-scope.md`, коммит `480d202f` («#398 spec revision 2 per SPEC-REVIEW-398-r1») - Заход: r2 · блокирующих циклов израсходовано до этого вердикта: 1 из 4 - Предыдущий вердикт: `docs/reviews/SPEC-REVIEW-398-r1.md`, жёлтый, на коммите `69dd09a7` (ревизия 1 ТЗ). SHA в вердикте r1 не назван явно текстом — найден сопоставлением: `git show a4b686a7 --stat` (коммит с самим документом r1) датирован позже `69dd09a7` и раньше `480d202f`, а «Материал» в r1 называет файл без ревизии — правки после которой ещё не было, то есть r1 читал ровно содержимое `69dd09a7`. ## Разбор по дельте (PROCESS.md §2.9, #214) Дельта — `git diff 69dd09a7..480d202f -- docs/specs/398-sysmodules-guard-scope.md` (28 добавленных / 22 удалённых строк). Правка не переезжает на другую подсистему, не меняет продуктовый контракт (задача остаётся `User-Visible: no`, класс B) и по объёму меньше исходной задачи — полный повторный разбор не требуется. Проверены только: (а) закрытие находки r1 по цитате нового текста и (б) те AC, чьё доказательство дельта задевает — это AC3, AC4 и, как показано ниже, обнаружилось, что дельта задевает ещё и совместимость AC1/AC2 с AC3 через код `pure_imports.py`, явно оставленный «в скоупе» правки. Разделы, которых дельта не касалась (Сценарий, UX, i18n, AC5, AC6, откат, release-артефакты), унаследованы из r1 — см. раздел ниже. ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | Medium: AC3 явно допускал «путь 2» (объявить `pure_imports.py` вторым легальным исключением), а AC4 требовал отсутствия лишних ключей после прогона — путь 2 конструктивно не мог пройти AC4 | «Путь 2» полностью удалён из текста; раздел «Что делать с `load_pure`» переписан как «один путь: снимать за собой», с явной ссылкой на находку ревьюера | `docs/specs/398-sysmodules-guard-scope.md:71-87` («Ревизия 1 допускала второй исход… вариант снят»); AC3 (строки 120-121) больше не содержит альтернативы «либо два файла»; план автотестов, п.4 (строка 139) убрал условность «если выбран путь 2» | Текстовое закрытие корректно и однозначно: альтернативный путь убран, а не спрятан («не рекомендуется», «как правило» и т.п. отсутствуют — использована формулировка «вариант снят»). ## Новая находка r2 ### Medium (в скоупе задачи) — устранение «пути 2» открывает новый конфликт: AC1/AC2 не совместимы с AC3 для `tests_backend/pure_imports.py`, оставленного «в скоупе» Ревизия 2 фиксирует ровно одно исключение из-под гварда — `tests_backend/conftest.py` (строка 87: «Единственное исключение остаётся одно»; AC3, строки 120-121: «остаётся единственным исключением»; план автотестов, п.4, строка 139: «попытка добавить **второй** файл в список исключений → красный» — то есть механизм исключения зафиксирован именно как список файлов мощности 1, а не как распознавание безопасного паттерна «запись + восстановление»). Одновременно раздел «Скоуп/не-скоуп» (строка 91) оставляет `tests_backend/pure_imports.py` в скоупе как файл, который правка изменяет (а не удаляет), и требует (через «Что делать с `load_pure`», строки 78-87, и AC5, строки 125-127), чтобы после правки `load_pure` продолжал резолвить относительные импорты для существующих вызовов — в частности, `tests_backend/test_junction_limits.py:28-30`, который передаёт канонический литерал `"custom_components.houseplan.junction_limits"` в качестве аргумента: ```python sys.modules[name] = module # tests_backend/pure_imports.py:31 ``` Это ровно образец, который AC1 называет напрямую: «Файл, записывающий `sys.modules[<переменная>] = …`, где переменная может содержать имя пакета интеграции, отклоняется гвардом» (строки 112-114). Значение `name` внутри `pure_imports.py` — параметр функции, из текста самого файла статически не определимо, что оно не начинается с `custom_components`; собственный fail-closed принцип ТЗ («значение ключа может быть неизвестно статически — тогда гвард обязан отказывать», строки 67-69) требует отклонить именно этот файл. Проверка в `test/backend-test-hygiene.test.mjs` статическая, идёт файл-за-файлом без исполнения (комментарий в коде, строки 11-14: «Проверка статическая — по исходникам, без запуска») — она не может увидеть, что `load_pure` восстанавливает `sys.modules` после `exec_module`; восстановление — рантайм-факт, а гвард текст не исполняет. Итог: `pure_imports.py`, оставленный в скоупе и обязанный по AC5 продолжать работать для `test_junction_limits.py`, **структурно не может** одновременно (а) не быть исключением (по AC3, ровно один файл-исключение — `conftest.py`) и (б) избежать отклонения по AC1 (поскольку пишет в `sys.modules` по переменной, которая в вызовах из скоупа действительно принимает значения вида `custom_components.houseplan.*`). Здесь нет обходного варианта на уровне формы записи: регистрация модуля в `sys.modules` под точным каноническим именем — обязательное условие резолва относительных импортов в Python (`importlib.util.module_from_spec` + `exec_module`), других способов сделать `from .wall_segment_model import …` рабочим для этого файла ТЗ не описывает и предложить не может без изменения самого механизма загрузки. **Почему это находка ТЗ, а не код-ревью.** Это тот же класс дефекта, что и закрытая находка r1: набор AC совместно невыполним для сценария, который сам документ называет обязательным (пункт «в скоупе» + AC5). Разработчик, реализующий ТЗ буквально — упрощённая по AC1/AC2 регулярка/AST-проверка плюс очистка `sys.modules` в `load_pure` — получит перманентно красный `test/backend-test-hygiene.test.mjs` на собственном `pure_imports.py`, то есть красный `npm test` сразу после реализации, и упрётся в противоречие, которое было видно уже на этапе чтения ТЗ. **Как править (для автора, не предписание).** Разрешить `pure_imports.py` вторым явным исключением в AC3 (список из двух конкретных, поимённо зафиксированных файлов — `conftest.py` и `pure_imports.py`, рост списка сверх этого по-прежнему красный) — это НЕ реинкарнация снятого «пути 2»: там исключение освобождало от очистки и потому ломало AC4; здесь очистка (уже специфицированная в ревизии 2 как «снимать всё, что появилось под префиксом `custom_components` за время `exec_module`») остаётся обязательной сама по себе и закрывает AC4 независимо от статуса файла в списке исключений статического гварда. Иными словами: исключение из AC1-проверки (статический факт «этому файлу разрешено писать») и обязанность очистки по AC4 (рантайм-факт «после прогона лишнего не осталось») — два независимых требования, и revision 2 верно оставила второе, но по инерции убрала и первое, хотя первое было не источником проблемы. Альтернатива — доказать в тексте, что `name` в `pure_imports.py` статически ограничен паттерном, не совпадающим с `custom_components*` (не просматривается, как это сделать без потери гибкости вызова с каноническим именем). **Воспроизведение** (разбор по коду, не исполнено как эксперимент — гварда, описанного ТЗ, ещё не существует в коде): применить контракт AC1 к `tests_backend/pure_imports.py:31` буквально даёт «отклонить», а вызов из `tests_backend/test_junction_limits.py:28` требует, чтобы файл продолжал работать. Оба требования одновременно предъявлены этой же ревизией ТЗ (AC1 + скоуп + AC5). ## Унаследовано из r1 Без повторной проверки в этом раунде — дельта не касалась этих участков текста и они не входят в цепочку доказательства новой находки: - Обязательные продуктовые разделы («Сценарий», «Что человек увидит до и после») и корректность персоны — `docs/reviews/SPEC-REVIEW-398-r1.md`, раздел «Что проверено и корректно», п.1, на SHA `69dd09a7` (текст этих разделов дельтой не тронут). Как проверить самостоятельно, что раздел не менялся: сравнить `git diff 69dd09a7..480d202f -- docs/specs/398-sysmodules-guard-scope.md` — «Сценарий» и «Что человек увидит» отсутствуют среди затронутых hunks. - Полнота обязательных разделов §7.1 (проблема, скоуп/не-скоуп, UX Н/П, модель данных и миграция Н/П, i18n Н/П, план автотестов, откат, release-артефакты) — там же, п.2. Структура не менялась, менялось только содержимое трёх блоков (проблема/контракт, AC3, план автотестов п.4), уже разобранных выше. - Соответствие `docs/SCOPE.md`: задача не претендует на строку Core user jobs, чисто инфраструктурная — там же, п.5. Не переоценивалось: дельта не меняет ни продуктовую рамку, ни `User-Visible`. - AC2, AC5, AC6 — однозначны и проверяемы каждый по отдельности — там же, п.4 (частично; см. ниже уточнение по AC1). Текст этих критериев дельтой не тронут (см. диф выше — правки только в AC3 и в тексте перед AC-блоком). Уточнение к унаследованному: r1 также назвал AC1 «однозначным» — это верно в изоляции (формулировка ясна), но r1 не проверял совместность AC1 с AC3 и скоупом; это ровно то, что находка r2 закрывает как пробел прошлого раунда, а не противоречие ему. - Риски «разбор Python из Node» и «скрытая зависимость от повторного импорта» — назывались по существу уже в r1, текст не менялся (третий риск, «снятие регистрации ломает относительные импорты», получил только уточнение «замер: модуль полностью рабочий после очистки» — редакционное, не меняет сути) — там же, п.6. Новый риск ревизии 2 («соседи, подтянутые относительными импортами») проверен заново в этом раунде, см. «Что проверено и корректно» ниже. - Откат и release-артефакты — не изменены дельтой, наследуются из п.7-8 того же документа. - Наблюдение 1 (напряжение формулировок «контракт vs риски» про способ разбора) и наблюдение 2 (маршрутизация issue мимо инфраструктурного пути) — не блокировали в r1 и не затронуты дельтой; остаются наблюдениями, не находками. ## Что проверено и корректно (в этом раунде) - Закрытие находки r1 текстуально полное: «путь 2» не просто ослаблен формулировкой, а вычеркнут вместе со всеми его следами в AC3 и плане автотестов (сверено построчно по диффу выше). - Новый риск «Соседи, подтянутые относительными импортами» (строки 163-167) назван конкретно, с перечислением реальных имён модулей (`wall_segment_model`, `coordinate_canonicalization`) и с механизмом смягчения (диф по префиксу вокруг `exec_module`, а не снятие одного имени) — проверено сопоставлением с фактическим поведением, описанным в разделе «Проблема и контракт» (те же три модуля названы там как реально оставшиеся после `import test_junction_limits`). - `docs/SCOPE.md` по-прежнему не нарушается: правка не расширяет заявленную поверхность, остаётся полностью инфраструктурной. ## Чего не проверял - Не гонял `npx tsc --noEmit` / `npm test` / `npm run build` — на этапе ревью ТЗ нет продуктового или тестового кода, есть только правка `docs/specs/*.md`; эти гейты неприменимы. (То же основание, что и в r1.) - Не проверял эксперимента «разбор синтаксиса Python из Node» — вопрос остаётся открытым наблюдением из r1, не заново поднятым в этом раунде. - Не перечитывал `tests_backend/test_validation.py` целиком повторно — его собственный `_load_pure` использует префикс `hp_pure.*`, статически не совпадающий с `custom_components*` (литеральная часть f-строки/конкатенации проверяема по AC2), и это уже подтверждено в r1; для новой находки этот файл не релевантен, она про `pure_imports.py`. - Не проверял, можно ли реализовать распознавание паттерна «запись сразу с последующим восстановлением» как альтернативу пофайловому списку исключений — это один из вариантов правки, adресованных автору, а не факт, который нужно доказывать ревьюеру; сам документ фиксирует именно файловый список (AC3 + план автотестов п.4), и находка оценивает именно этот, явно выбранный автором механизм. ## Вердикт Жёлтый. Единственная блокирующая находка (Medium, в скоупе задачи) — устраняя находку r1 удалением «пути 2», ревизия 2 разорвала совместность AC1/AC2 (гвард обязан отклонить запись `pure_imports.py:31` по правилу fail-closed) и AC3 (единственное разрешённое исключение — только `conftest.py`) при том, что `pure_imports.py` явно оставлен в скоупе и обязан по AC5 продолжать работать для `test_junction_limits.py`. Правится в тексте ТЗ тем же автором, без выхода за рамки текущей задачи — вероятный минимальный фикс: вернуть `pure_imports.py` в список статических исключений (не путать со снятым «путём 2» — обязанность очистки `sys.modules` по AC4 остаётся в силе независимо от статуса файла в списке исключений гварда).