Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
11 KiB
ТЗ #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).
- Доки: не задеты.