20 KiB
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 не тронут вовсе.
Как проверялось
- Прочитан ТЗ
docs/specs/421-negative-test-proofs.mdнаfac1f884целиком, оба документа spec-review,docs/SCOPE.md, разделы 2.7/2.9/2.10/7/8/12PROCESS.md. git diff origin/dev...HEADразобран файл за файлом (см. «Скоуп»).- Для каждого AC1–AC9 сверено соответствие тесту/коду построчно (см. «Что проверено и корректно»).
- Прогнаны гейты, не покрытые дешёвым CI-прогоном (см. таблицу гейтов).
- Прочитан 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/discardreplacement["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.
Материал раунда
- Ветка:
origin/issue/421-negative-test-proofs - SHA:
f4b425f3c0baf4f326a30669cc23a5f63c4bbaad - Диапазон:
origin/dev..HEAD(5 коммитов, 1 из них — код) - Validate CI: https://github.com/Matysh/houseplan-card/actions/runs/33660048919 (success)
Вердикт
Зелёный. Три заявленных разрыва доказательности закрыты адресно и без
расширения скоупа: AC1–AC3 логически доказаны и подтверждены зелёным backend
CI-прогоном (сам pytest-запуск трёх новых --id= мутантов лично не
воспроизведён — нет backend-окружения в песочнице ревью, см. «Чего не
проверял»), AC4–AC7 воспроизведены мной лично как красный→пойман результат
исполнением. Production-поведение не изменено (AC9). High: 0, Medium: 0.
Материал раунда
- Ветка:
issue/421-negative-test-proofs, коммитf4b425f3c0ba— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
87d4684aa6e067a201e82ca3b690e0c1a5b55df1git log --all --format='%H %T' | grep 87d4684aa6e0 - ТЗ
docs/specs/421-negative-test-proofs.md, блоб41cd21d1f105502a9c9a7464af0d4b199ae322c9git log --all --find-object=41cd21d1f105502a9c9a7464af0d4b199ae322c9 -- docs/specs/421-negative-test-proofs.md