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

11 KiB
Raw Permalink Blame History

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