Files
houseplan-card/docs/reviews/CODE-REVIEW-421-r1.md
T
2026-09-02 17:32:18 +00:00

20 KiB
Raw Blame History

CODE-REVIEW-421-r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/421
  • Этап: S6-code-review (код-ревью, PROCESS.md §2.7)
  • Материал: git log --oneline origin/dev..HEAD, git diff origin/dev...HEAD на коммите f4b425f3c0baf4f326a30669cc23a5f63c4bbaad (ветка origin/issue/421-negative-test-proofs, HEAD detached на этом SHA).
  • Заход: r1 (первый код-ревью раунда, блокирующих циклов израсходовано 0 из 4). ТЗ прошло два раунда spec-review (docs/reviews/SPEC-REVIEW-421-r1.md, docs/reviews/SPEC-REVIEW-421-r2.md), итоговый вердикт — зелёный на fac1f884.

Скоуп

Единственный коммит реализации — f4b425f3 («test: prove three defensive contracts fail red»). Остальные 4 коммита в диапазоне — документация ТЗ и ревью ТЗ (c409ec6a, 7f9b9198, fac1f884, 03cc6063), кода не меняют.

Изменённые файлы f4b425f3:

  • custom_components/houseplan/websocket_api.py — clock seam _support_monotonic() (обёртка над time.monotonic());
  • tests_backend/test_ha_websocket.py — реальные submit-проверки replacement/discard/TTL вместо discard-only идемпотентности;
  • demo/guard/guard_report_page_errors.mjs (новый) — отдельная browser probe через reportPageErrors(), без finish();
  • demo/guard/verify-guard.mjs — регистрация новой probe;
  • scripts/docs-accept.mjs — вынесенная экспортируемая acceptedDocsManifest(), main() теперь вызывает её как единственный путь;
  • test/docs-accept.test.mjs — unit-матрица новой/сохранённой acceptance trace;
  • scripts/mutation-gate.mjs — 5 новых адресных мутантов (support-preview-replacement-keeps-old-token, support-preview-discard-keeps-token, support-preview-submit-skips-ttl-prune, report-page-errors-skips-round-trip, docs-fingerprint-refresh-erases-acceptance-trace);
  • test/smoke-exception-guard.test.mjs, test/smoke-harness-contract.test.mjs — контракт на новую probe (обнаружимость, отсутствие finish(), счётчик verify-guard.mjs в реестре);
  • docs/specs/README.md — строка issue↔ТЗ.

Production-код (websocket_api.py) изменён минимально: три существующих разрыва доказательности не потребовали фикса поведения (ТЗ предполагало это как вероятный исход, «принятые предположения»/«затрагиваемые файлы») — только добавлен monkeypatch-совместимый clock seam, без изменения логики replacement/discard/TTL/prune. demo/serve.mjs не тронут вовсе.

Как проверялось

  1. Прочитан ТЗ docs/specs/421-negative-test-proofs.md на fac1f884 целиком, оба документа spec-review, docs/SCOPE.md, разделы 2.7/2.9/2.10/7/8/12 PROCESS.md.
  2. git diff origin/dev...HEAD разобран файл за файлом (см. «Скоуп»).
  3. Для каждого AC1–AC9 сверено соответствие тесту/коду построчно (см. «Что проверено и корректно»).
  4. Прогнаны гейты, не покрытые дешёвым CI-прогоном (см. таблицу гейтов).
  5. Прочитан production-код seam'ов (websocket_api.py:234-253, 2181-2253, 2284-2317; demo/serve.mjs:44-121), чтобы подтвердить, что анкоры мутантов адресуют именно то, что описывает ТЗ, а не соседний код.

Гейты

Validate на этом SHA (f4b425f3) зелёный целиком: https://github.com/Matysh/houseplan-card/actions/runs/33660048919 — все job, включая «Бэкенд: pytest в Home Assistant», «Фронтенд: типы, юниты, мутанты, синхрон бандла» (typecheck/npm test/npm run build/bundle-tree), «Golden», «Смоки в браузере (3 шарда)», «Перф-смок» — success. Дешёвые гейты (tsc, npm test, build+bundle sync) и pytest tests_backend этим прогоном переиспользованы, повторно не гонялись.

Diff не трогает src/** — node scripts/check-docs.mjs не требуется (подтверждено node scripts/smoke-select.mjs --base origin/dev --head HEAD: «Исполняемого frontend-диффа нет»). Diff не трогает геометрию/layout/ marker.space/толщину стен — model-invariants не требуются. Golden/perf уже зелёные тем же CI-прогоном, доп. прогон не нужен: AC9 явно не требует пиксельных доказательств.

Не покрыто Validate (это release/mutation-gate, отдельный workflow, не запускается на каждом обычном прогоне) — прогнано мной:

Гейт Команда Результат
Применимость патчей node scripts/mutation-gate.mjs --check ok по всем мутантам реестра, включая 5 новых — анкоры существуют в текущем коде ровно один раз
AC7 (docs acceptance) node scripts/mutation-gate.mjs --id=docs-fingerprint-refresh-erases-acceptance-trace чистый прогон ok, с мутантом — тест покраснел, как обязан, «поймано 1 из 1»
AC4+AC5 (browser probe) node scripts/mutation-gate.mjs --id=report-page-errors-skips-round-trip чистый прогон node demo/guard/verify-guard.mjs — ok (реальный Chromium, все 4 probe включая новую отработали штатно); с мутантом — «поймано 1 из 1»
AC1 (replacement) node scripts/mutation-gate.mjs --id=support-preview-replacement-keeps-old-token не выполнено — локальный питон без pytest/pytest-homeassistant-custom-component/homeassistant (ошибка No module named pytest); заменено чтением кода, см. ниже
AC2 (discard) --id=support-preview-discard-keeps-token то же ограничение, то же замещение чтением
AC3 (TTL) --id=support-preview-submit-skips-ttl-prune то же ограничение, то же замещение чтением

Для трёх backend-мутантов (AC1–AC3) отрицательный прогон проверен чтением, не исполнением: --check подтвердил, что анкоры патчей (websocket_api.py:2196-2198, 2251-2252, 2284-2285) — точные, единственные вхождения в текущем коде. Построчный разбор функций ws_support_preview/ws_support_preview_discard/ ws_support_submit (см. «Что проверено и корректно») показывает, что удаление каждого анкора именно ломает ту assert-пару, которую добавил новый тест (support_preview_expired / число вызовов submitted) — логика однозначна, альтернативных путей, которые сохранили бы прежнее поведение без этого кода, нет. Реальный запуск pytest этих трёх мутантов — открытый пункт, см. «Чего не проверял».

Находки

Нет. High: 0, Medium: 0, Low: 0.

Что проверено и корректно

  • AC1 (replacement). test_support_preview_replacement_and_discard_are_draft_local создаёт три токена (два — draft-same-card, один — draft-other-card), мокает async_submit_report. После замены first["token"] (старый токен того же драфта) отвечает support_preview_expired (tests_backend/test_ha_websocket.py:1362-1365); other["token"] (другой draft_id) успешно доходит до транспорта с точными байтами (:1367-1371, сверено с other["text"].encode("utf-8")). Код: цикл замены websocket_api.py:2196-2198 — анкор мутанта support-preview-replacement-keeps-old-token совпадает буквально; без него first["token"] остаётся в rt.support_previews, и submit прошёл бы успешно, а не с support_preview_expired — тест ловит именно это.
  • AC2 (discard). После preview/discard replacement["token"] submit отвечает support_preview_expired, len(submitted) == 1 (транспорт не вызван повторно) — :1383-1390. Discard уже удалённого first["token"] остаётся идемпотентным {ok: true} — тест это явно использует, а не выдаёт за доказательство удаления (замечание r1 ТЗ учтено буквально: успех discard больше не единственное доказательство). Код: pop в ws_support_preview_discard — websocket_api.py:2251 — анкор мутанта support-preview-discard-keeps-token точный; без pop токен остался бы в карте, submit прошёл бы успешно вместо support_preview_expired.
  • AC3 (TTL). test_support_preview_token_expires_at_ttl_without_transport подменяет _support_monotonic управляемым словарём clock, без sleep. До границы (TTL - 1) submit успешен и байты совпадают (:1416-1419); на границе (now >= expires, TTL секунд от создания) submit отвечает support_preview_expired, len(submitted) == 1 — relay не вызван (:1421-1428). Код: _prune_support_previews(rt) перед чтением токена в ws_support_submit — websocket_api.py:2284 — анкор мутанта support-preview-submit-skips-ttl-prune точный; без prune истёкшая запись осталась бы в карте и submit прошёл бы успешно.
  • Идемпотентность ключей. Каждый submit в новых тестах использует свой idempotency_key (replaced-token-proof, other-draft-proof, discarded-token-proof, before-ttl-proof, at-ttl-proof) — риск ТЗ «результат маскируется idempotency-кэшем» закрыт буквально. Rate-limit (MAX_SUPPORT_PREVIEWS_PER_USER = 3) не задет: подсчёт owned в websocket_api.py:2199 идёт уже после цикла замены, число одновременно живых токенов на владельца в обоих тестах не превышает 2.
  • AC4/AC5 (browser probe). guard_report_page_errors.mjs создаёт page error в хвосте (setTimeout(..., 0)), не вызывает finish(), только await reportPageErrors() — совпадает с контрактом дословно. Прочитан demo/serve.mjs:44-121: reportPageErrors() и finish() независимо each вызывают roundTripLivePages() — это две реально разные функции, мутант адресует ровно тело reportPageErrors, анкор уникален (подтверждено --check). Реальный прогон verify-guard.mjs (Chromium) — без мутанта все 4 probe зелёные, с мутантом (--id=report-page-errors-skips-round-trip) — «поймано 1 из 1» — AC4 и AC5 доказаны исполнением, не только чтением. test/smoke-harness-contract.test.mjs фиксирует source-контракт (нет finish(), есть reportPageErrors()), test/smoke-exception-guard.test.mjs — обнаружимость 4 проб и регистрацию 3 мутантов реестра (двух старых плюс нового) с 3 вызовами verify-guard.mjs в реестре.
  • AC6/AC7 (docs acceptance). acceptedDocsManifest() — чистая функция без мутации входов (test/docs-accept.test.mjs явно проверяет 'acceptance' in manifest === false и неравенство ссылок; previousAcceptance тоже не мутируется — сверено structuredClone). Три ветки покрыты: реальная замена (declared/witnesses/floor + witnessesSkippedBecause при флаге), fingerprint-only с прежним trace (все прежние поля плюс lastWriteWasFingerprintOnly: true, включая произвольное поле future), fingerprint-only без прежнего trace (только lastWriteWasFingerprintOnly). main() в scripts/docs-accept.mjs вызывает эту же функцию — дублирующей inline-сборки не осталось (сверено диффом). Мутант docs-fingerprint-refresh-erases-acceptance-trace подменяет ветку fingerprint-only на пустой trace; guard — --test-name-pattern="fingerprint-only refresh", совпадает с названием теста #421 fingerprint-only refresh preserves the complete previous acceptance trace. Прогнан лично — красный без фикса, пойман. AC7 требовал именно этого теста, а не теста docsAcceptancePlan() — верно, мутация патчит только docs-accept.mjs, к планировщику отношения не имеет.
  • AC8. --check — все патчи (старые и 5 новых) анкорятся ровно один раз. tsc --noEmit/npm test/build+bundle sync/pytest tests_backend — зелёные на этом же SHA тем же Validate-прогоном.
  • AC9. websocket_api.py меняет только имя вызова часов (time.monotonic() → _support_monotonic(), тождественная реализация по умолчанию) — публичный контракт, коды ошибок, схема, пиксели не меняются. docs-accept.mjs — чистый рефакторинг существующей inline-сборки в экспортируемую функцию с тем же поведением (сверено построчно со старым кодом в диффе). User-Visible: no на всех 5 коммитов — верно, changelog не тронут ни в RU, ни в EN.
  • Трейлеры. Все 5 коммитов несут Issue: #421 и User-Visible: no; ветка issue/421-negative-test-proofs соответствует конвенции.
  • Не-скоуп ТЗ соблюдён. WebSocket API, коды ошибок, TTL-значение, idempotency-семантика, UI Help & feedback, browser smoke harness, правила приёмки скриншотов — не изменены; изменения точечные, ровно по трём названным швам.

Чего не проверял

  • Реальный pytest-прогон трёх новых backend-мутантов (support-preview-replacement-keeps-old-token, support-preview-discard-keeps-token, support-preview-submit-skips-ttl-prune) — окружение ревью не содержит pytest/pytest-homeassistant-custom-component/homeassistant (ModuleNotFoundError/No module named pytest). Заменено чтением кода (см. таблицу гейтов и «Что проверено»). Это открытый остаток: AC1–AC3 доказаны тестом на исполнении (backend job Validate зелёный на правильном коде) и логическим разбором мутанта, но не отдельным красным прогоном каждого --id= лично мной.
  • Полный node scripts/mutation-gate.mjs (без --id/--check) — дорогой release-гейт по всему реестру (~сотня мутантов), задача явно выводит его за рамки обычного прогона («Производительность» ТЗ); прогнаны только 5 новых адресных id плюс --check.
  • npm run golden:verify, python -m pytest tests_backend -q (полный, не по одному тесту), npx tsc --noEmit, npm test, npm run build — не перезапускались локально; переиспользован зелёный Validate-прогон на этом же SHA (f4b425f3), гейты дешёвые и не расходятся с diff.
  • docs/TESTING.md — не проверял на необходимость доп. описания новых guard ID: файл не упоминает существующие probe по именам вообще (только общие команды --list/--check/--changed), поэтому новые ID обнаружимы тем же путём без правки runbook; ТЗ считало это опциональным.
  • Ручное UI-тестирование — не проводилось: изменение не имеет видимой поверхности (User-Visible: no, подтверждено AC9), а сценарий задачи — это разработчик, ломающий гейт, а не персона из docs/SCOPE.md.

Материал раунда

Вердикт

Зелёный. Три заявленных разрыва доказательности закрыты адресно и без расширения скоупа: AC1–AC3 логически доказаны и подтверждены зелёным backend CI-прогоном (сам pytest-запуск трёх новых --id= мутантов лично не воспроизведён — нет backend-окружения в песочнице ревью, см. «Чего не проверял»), AC4–AC7 воспроизведены мной лично как красный→пойман результат исполнением. Production-поведение не изменено (AC9). High: 0, Medium: 0.


Материал раунда

  • Ветка: issue/421-negative-test-proofs, коммит f4b425f3c0ba — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 87d4684aa6e067a201e82ca3b690e0c1a5b55df1
    git log --all --format='%H %T' | grep 87d4684aa6e0
    
  • ТЗ docs/specs/421-negative-test-proofs.md, блоб 41cd21d1f105502a9c9a7464af0d4b199ae322c9
    git log --all --find-object=41cd21d1f105502a9c9a7464af0d4b199ae322c9 -- docs/specs/421-negative-test-proofs.md