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

250 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
## Материал раунда
- Ветка: `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.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `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
```