Files
houseplan-card/docs/specs/385-audit-lows.md
T
2026-08-30 10:44:28 +03:00

145 lines
11 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.
# ТЗ #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).
- Доки: не задеты.