# ТЗ #385 — Пачка Low аудита v1.69.0 (а–г) - Issue: https://github.com/Matysh/houseplan-card/issues/385 - Приоритет: P3, polish; полный трек (четыре несвязанные поверхности — фронт-UI, фронт-модель, инфраструктурный скрипт, бэкенд; критерий «одна поверхность» §5 не выполняется — прецеденты #369, SPEC-REVIEW-376-r1 H1) - Ревизия: 2 (2026-08-30) — по SPEC-REVIEW-385-r1 (M1 + Low) ## Сценарий Четыре шероховатости, найденные 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). Контракт: проверка вычисляется только для коммитов, для которых `isRelease` истинно, и предикат — это **буквально вынесенное целиком выражение из `process-gate.mjs:133-135`, оба дизъюнкта**: `(/^Release v\d/.test(subject) && !/-(beta|rc|alpha)\.|candidate/i.test(subject)) || Boolean(one('Release'))` — НЕ пересказ по памяти и НЕ только первый дизъюнкт. Второй дизъюнкт (`Release:`-трейлер) уже сегодня делает `isRelease` истинным для штатных бета-коммитов приёмки эталонов (пример: `5f6ee657`), и суженный предикат дал бы им `releaseSourceViolations: null` при `isRelease: true` → `evaluateCommit` (:196-203) счёл бы ВСЕ их src-файлы нарушением → ложный отказ pre-push/CI. Реализация: выражение выносится в одну функцию (например, `isReleaseSubject(subject, one)`), которую используют И `makeCommit`, И гейт вычисления в `parseRecords`; AC4 доказывает переиспользование. Семантика гейта не меняется: для нерелизных `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 — не влияет; производительность — (в) сокращает работу pre-push/CI-гейта на длинных диапазонах (только выигрыш, бенч не требуется), продуктовый рантайм не задет. ## Release-артефакты - CHANGELOG + CHANGELOG.ru: user-visible запись только про (а) («повторный клик по выбранной привязке больше не сбрасывает источник значения»), ссылка #385. (б)–(г) видимого поведения не меняют и в changelog НЕ входят (согласовано с разделом UX; коммит с ними — User-Visible: no). - Доки: не задеты.