diff --git a/docs/specs/385-audit-lows.md b/docs/specs/385-audit-lows.md new file mode 100644 index 00000000..38155047 --- /dev/null +++ b/docs/specs/385-audit-lows.md @@ -0,0 +1,130 @@ +# ТЗ #385 — Пачка Low аудита v1.69.0 (а–г) + +- Issue: https://github.com/Matysh/houseplan-card/issues/385 +- Приоритет: P3, polish; полный трек (четыре несвязанные поверхности — фронт-UI, + фронт-модель, инфраструктурный скрипт, бэкенд; критерий «одна поверхность» §5 + не выполняется — прецеденты #369, SPEC-REVIEW-376-r1 H1) +- Ревизия: 1 (2026-08-30) + +## Сценарий + +Четыре шероховатости, найденные adversarial-аудитом стабильной v1.69.0 — каждая +мала, вместе — гигиена свежего кода #378/#379. Пользователь видит одно: (а) +случайный повторный клик по уже выбранному binding в диалоге маркера больше не +стирает молча настроенный источник значения (и «Значение + состояние», и +бейджа). Остальное — невидимая честность модели, гейта и экспорта. + +## Что человек увидит до и после + +**До**: повторный клик по своему же binding сбрасывает выбранный источник +значения на «авто» без предупреждения. **После**: клик по уже выбранному +кандидату — no-op; сброс происходит только при явной смене binding (буква +спеки #378 §1.6). Пункты (б)–(г) видимого поведения не меняют. + +## Проблема / Контракты по пунктам + +**(а) Сброс value_source и value_badge при клике по тому же binding.** +`houseplan-editor-runtime.ts:12299-12310`: обработчик кандидата безусловно +пишет `valueSource: null, valueSourceTouched: true` и зовёт +`_valueBadgeForBinding` (:11961), который так же безусловно touched. Контракт: +если `c.value === d.binding` — обработчик выходит без изменений драфта (кроме +закрытия списка `bindingOpen: false`); при реальной смене — поведение прежнее. +Чинится в обоих местах выбора кандидата (:12308 и виртуальная ветка :12260 — +для неё «тот же binding» = `d.binding === 'virtual'`). + +**(б) `rewriteMarkerControlReferences` пишет ключ `value_source: undefined`.** +`src/devices.ts:886-892`: при переписывании ссылок маркера в объект попадает +ключ со значением `undefined` (JSON его отбрасывает — до бэкенда не доходит, +но hasOwnProperty-семантика полей отличается от исходного маркера). Контракт: +ключ не пишется вовсе, если значения нет (условный спред). + +**(в) `releaseSourceViolationsOf` для каждого коммита диапазона.** +`scripts/process-gate.mjs:139-155`: `parseRecords` зовёт дорогую проверку +(2×`git show` на каждый src-файл) для КАЖДОГО коммита, хотя потребляется она +только для стабильных релизных (`/^Release v\d/` без beta/candidate, :134). +Контракт: проверка вычисляется только для коммитов, чей subject проходит тот +же релизный предикат (вынести предикат в общую константу/функцию, чтобы гейт +и вычислитель не разошлись). Семантика гейта не меняется: для нерелизных +`releaseSourceViolations` остаётся null, как и сегодня при `() => null`. + +**(г) Асимметрия обезвреживания внешних ссылок при экспорте.** +`custom_components/houseplan/import_export.py:504-519`: внешний +`value_badge`-ref нейтрализуется полями (`enabled=False, source=None`), а +`value_source` — удалением ключа (`marker.pop`). Контракт: `value_source` +приводится к формату бейджа по смыслу — внешняя ссылка заменяется на +отсутствие источника ТЕМ ЖЕ способом, каким это представляет фронт: ключ +`value_source` удаляется у ОБОИХ? Нет — решение: оставить существующие +форматы (у бейджа поле enabled — часть его модели, у value_source отсутствие +ключа = auto — часть его модели), но зафиксировать симметрию НАМЕРЕНИЯ юнитом: +оба пути дают `dropped_marker_links` +1 и валидный по схеме маркер, и оба +документируются одним комментарием-парой в коде. (Формат хранения менять +нельзя — это ломало бы round-trip уже существующих экспортов.) + +## Скоуп / Не-скоуп + +- Скоуп: четыре правки выше + тесты + ченджлог. +- Не-скоуп: изменение формата хранения `value_badge`/`value_source` (г); + любой новый UI; изменение семантики гейта (в). + +## UX + +Видимых изменений нет, кроме (а): исчезает молчаливый сброс. Новых строк нет. + +## Модель данных и миграция + +Нет. (б) убирает фантомный ключ до записи; (г) форматы хранения не меняются. + +## i18n + +Не задето. + +## Критерии приёмки + +- **AC1** (а, юнит/смок): в открытом диалоге маркера с выбранным + `value_source` клик по УЖЕ выбранному binding-кандидату не меняет + `valueSource`/`valueSourceTouched` и настройки бейджа; клик по ДРУГОМУ + кандидату сбрасывает, как раньше (регресс-ветка). +- **AC2** (а, зеркало): то же для value_badge: повторный клик не трогает + `valueBadge*`-поля драфта. +- **AC3** (б, юнит): результат `rewriteMarkerControlReferences` для маркера + без `value_source` не содержит ключа `value_source` + (`'value_source' in marker === false`); с внешним `value_source` — ключ + переписан корректно (существующее поведение). +- **AC4** (в, юнит): `parseRecords` на диапазоне из N нерелизных коммитов не + вызывает вычислитель ни разу (счётчик-шпион); релизный коммит — вызывает + ровно один раз; классификация релизности — та же функция, что в гейте + (юнит сверяет идентичность/переиспользование предиката). +- **AC5** (г, pytest): экспорт пространства с внешними `value_badge`-ref И + `value_source`-ref даёт `dropped_marker_links == 2`, оба маркера валидны по + схеме, `value_badge.enabled is False`, `"value_source" not in marker`. +- **AC6**: полный гейт зелёный; бюджет ≈ 0. + +## План автотестов + +- node --test: AC1/AC2 (диалоговый драфт; паттерн существующих тестов + `_valueBadgeForBinding`), AC3 (devices), AC4 (process-gate — тесты гейта уже + существуют в test/, добавить кейс со шпионом). +- pytest: AC5 в существующем файле экспорта. +- Мутанты (mutation-gate): м1 — вернуть безусловный сброс в (а) → красный AC1; + м2 — вернуть безусловный вызов в (в) → красный AC4. + +## Риски + +- (а): единственная поведенческая правка; риск — пропустить третий путь выбора + binding (поиск по `_valueBadgeForBinding` даёт два вызова: :12260, :12308 — + оба в скоупе; ревьюеру — перепроверить grep'ом). +- (в): риск рассинхронизации предиката релизности — гасится AC4 (общая функция). + +## Откат + +Обычный `git revert`: флагов, миграций, персистентных данных нет. Откат (а) +возвращает молчаливый сброс, остальное — косметика. + +**DoR-примечания:** миграция/compatibility — нет; touch — не влияет. + +## Release-артефакты + +- CHANGELOG + CHANGELOG.ru: user-visible запись про (а) («повторный клик по + выбранной привязке больше не сбрасывает источник значения»), (б)–(г) — + «мелкие уточнения»; ссылка #385. +- Доки: не задеты.