# #492 — Точный кандидат интеграции и полный manifest входов selection/reuse - **Issue:** https://github.com/Matysh/houseplan-card/issues/492 - **Тип / приоритет:** infra, tech-debt / P1 - **Трек:** полный — четыре поверхности протокола CI (слияние, реюз, классификация, отбор мутантов), без файлов класса A - **Оценка:** ценность для разработки 9/10; сложность 6/10; риск 4/10 (протокол, который сам себя проверяет, — ошибка в нём делает зелёный ничего не значащим) - **Связано:** аудит 2026-09-08 §11 п.3 (I1/I2); #208 (реюз), #312 (материал ревью), #364 (dev ушёл), #430 (оснастка смоков), #475 (отбор по гарду), #479 (тяжёлые гейты), #481 (журнал), `PROCESS.md` §7.2, §8, §10.3, §11.4 ## 1. Проблема Зелёный вердикт и зелёный Validate обязаны означать одно: *именно этот код* проверен *всеми проверками, чьи входы менялись*. Сейчас это верно не всегда — четыре механизма, подтверждённые чтением `ea6061e9`. **1. Сливается не то дерево, что проверено.** `process.yml` фиксирует материал ревью (`steps.material`), после зелёного вердикта сверяет вершину ветки с материалом (#312), затем делает `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev` продвинулся за время ревью (28 августа — четыре раза за день), результат ребейза — новое дерево, которое никто не проверял: ни ревью (материал другой), ни Validate (на этот SHA он не бежал). Предупреждение #364 пишет комментарий и не блокирует. Минимальный git-эксперимент аудита: чистый ребейз, вердикт принят, поведение `20 → 40`. **2. Реюз бэкенда не видит часть своих входов.** Job `backend` исполняет: pytest `tests_backend/`, `python -m unittest discover -s scripts/support-relay/tests`, ruff/mypy по `pyproject.toml`, порог покрытия. Тесты читают `scripts/sh3d-convert/golden/*.json`, `scripts/sh3d-convert/convert.mjs` (константы версий), `scripts/dump-config-schema.py`, `scripts/config-schema.json`. Ключ реюза (`HARNESS.backend` в `gate-reuse.mjs`) знает `tests_backend`, `custom_components/**/*.py`, `pytest.ini`, baseline и `pyproject.toml` — и **не знает** relay, converter, schema. Прямой случай: правка только `scripts/support-relay/relay.py` даёт `backend=true` в `changes` (регэксп классификатора), но ключ реюза не меняется — job отменяется как переиспользованная, и тесты relay не бегут. Классификатор `backend` при этом не видит `pyproject.toml` и `scripts/sh3d-convert/`: правка только их не запускает job вовсе. **3. Golden и перф не видят протокол харнеса.** `demo/golden/run.mjs` импортирует `../serve.mjs` и `../bundle-freshness.mjs`; бенчмарки — `serve.mjs`, `editor-runtime-compat.mjs`, `bundle-freshness.mjs`; `serve.mjs` отдаёт страницу `demo/srv/demo.html`. Ключи `golden` и `performance_smoke` держат только `demo/golden/**` и `demo/performance/** + два benchmark`; `serve.mjs` есть лишь у `smoke` (#430), `demo/srv/demo.html` и compat-хелперы — ни у кого. Обратный перекос: `sourceFingerprint` (`src/**`) подмешан во **все** ключи, и любая правка UI сбрасывает реюз бэкенда, который UI не исполняет. **4. Отбор мутантов теряет обёртки и новые определения.** `guardFiles()` берёт из строки гарда только токены-пути. 10 из 35 гардов `scripts/backend-test-guard.mjs` не передают третий аргумент и работают по умолчанию с `tests_backend/test_ha_import_export.py` — правка этого файла не отбирает их (#475 промахивается ровно так, как описано в аудите). `scripts/trail-resume-test-guard.mjs` держит пути тестов внутри себя — свидетели `vacuum-trail-*` не отбираются и не меняют отпечаток журнала (#481) при правке `test_trails.py`. Смоки импортируют `serve.mjs` и фикстуры — их изменение тоже не отбирает. При диффе только по `scripts/mutation-gate.mjs` job `changed_mutants` запускается (#475 r1), но `selectChangedMutants` пересекает дифф с файлами патчей и гардов — новый мутант, не трогающий их, не выбирается: «реестр изменился» не превращается в «новый свидетель прогнан». **5. Ночной прогон рапортует об очереди, не о результате.** `nightly.yml` делает `gh workflow run validate.yml … -f full=true` и завершается успехом в момент постановки в очередь; красный полный прогон не делает ночной workflow красным. ## 1.1. Сценарий Персоны: автор задачи, ревьюер, обслуживающий чат, владелец, читающий статусы. Все они читают «зелёный» как «проверено». Задача — сделать это чтение верным на четырёх швах, не удлиняя обычный путь: ветка, не пересекающаяся с движением `dev`, и правка, не трогающая входов job, проходят так же быстро, как сейчас. ## 1.2. Что человек увидит до и после До: вердикт зелёный → `S8-merged`, в `dev` — ребейзнутое дерево без прогона; правка relay зелёная за секунды, потому что тесты relay не бежали; новый мутант в реестре зелёный, потому что не выбран. После: если `dev` ушёл — конвейер публикует точный кандидат в ветку, ждёт Validate на этом SHA и только потом двигает `dev` с `--force-with-lease` на ожидаемую базу; в issue — строка «кандидат `abc1234` (ребейз на `dev@def5678`), Validate зелёный, слито». Правка relay/converter/schema/serve.mjs/demo.html запускает и не переиспользует те job, которые их исполняют. Новый или изменённый мутант бежит на первом же пуше. Ночной workflow красный, когда красный Validate. ## 2. Скоуп 1. **Слияние точного кандидата** (§4): проверка нового дерева перед push в `dev`, безопасная обработка повторного движения `dev`, равенство patch-id проверенного и сливаемого диффа. 2. **Единый manifest входов** (§5): один модуль объявляет входы каждой проверки по категориям source / tests / fixtures / config / toolchain / protocol; из него выводятся и классификация (`changes`), и ключ реюза; неизвестный исполняемый вход расширяет проверки; лишняя зависимость бэкенда от `src/**` снимается после доказанной полноты. 3. **Замыкание входов гарда мутанта** (§6): обёртки объявляют свои входы, импорты и фикстуры гардов входят в отбор и отпечаток; изменённые/новые определения реестра отбираются явно. 4. **Ночной прогон ждёт результат** (§7). 5. **Отрицательные тесты протокола** (§8): по каждому представительному входу доказано отсутствие false-green. ## 3. Не-скоуп - Продуктовый код (`src/**`, `custom_components/**/*.py`) — если замыкание найдёт дефект продукта, он заводится отдельным issue. - Перечень и содержание самих проверок Validate (какие job существуют, их бюджеты). - Замена ревью-конвейера или изменение правил §7.2 о повторном ревью: при чистом ребейзе с равным patch-id вердикт остаётся в силе; при изменившемся диффе — повторное ревью по существующим правилам. - Полный ночной набор (#479) остаётся полным; реюз на кандидатах по-прежнему невозможен (версия входит в manifest source). - Отбор мутантов по транзитивным зависимостям **внутри `src/**`** — сторона патча остаётся точечной (§6.4). ## 4. Слияние точного кандидата ### 4.1. Решение выносится в скрипт Логика шага «Слить ветку в dev» переезжает из shell в `scripts/merge-candidate.mjs` с чистой функцией `decideMerge(state)` и исполняющей обёрткой; `process.yml` вызывает скрипт. Состояние: `material` (SHA материала), `materialBase` (merge-base материала с `dev` на момент фиксации), `actual` (вершина ветки), `devNow`, `candidate` (результат ребейза `actual` на `devNow`), patch-id диффов `materialBase..material` и `devNow..candidate`, результат Validate на `candidate`, номер попытки. ### 4.2. Алгоритм 1. Проверка #312 как сейчас: `actual` = `material` либо `material` + один коммит документа ревью. 2. `devNow == materialBase` → `dev` не двигался, `candidate == actual` → push `HEAD:dev` c `--force-with-lease=refs/heads/dev:$devNow` (fast-forward). Как сейчас, плюс lease. 3. `dev` двигался → ребейз (конфликт — как сейчас, `S6-in-progress`). Сравнение patch-id: если дифф ветки после ребейза отличается от проверенного (контекст/содержимое патча изменились из-за соседних правок), кандидат публикуется в ветку, задача возвращается в `S7-code-review` с комментарием «дифф изменился при ребейзе, нужен новый заход ревью (§7.2)» — без слияния. 4. Patch-id равен → кандидат публикуется в ветку задачи (`--force-with-lease=refs/heads/$BRANCH:$actual`); пуш PAT-ом запускает Validate на `candidate`. Скрипт находит прогон по SHA (`gh run list --workflow validate.yml --commit`) и ждёт завершения (`gh run watch --exit-status`, лимит 45 мин; отсутствие прогона в течение 3 мин — ошибка, не «зелёный»). 5. Validate красный → `S6-in-progress`, комментарий с ссылкой на прогон: «кандидат после ребейза на `dev@…` красный». 6. Validate зелёный → `git push --force-with-lease=refs/heads/dev:$devNow candidate:dev`. Lease отклонён (`dev` двинулся снова) → `fetch`, новая попытка с п.3; не более **3** попыток, после — `S6-in-progress` с комментарием «dev движется быстрее слияния, повторить». 7. `S8-merged` — только после успешного push (инвариант «метка не врёт» сохраняется). Что считается проверкой нового дерева: Validate на ветке — лёгкий набор плюс диффозависимые гейты (`changed_mutants` по `dev..candidate`, перф-профили по диффу). Тяжёлые гейты по #479 остаются за кандидатом релиза; это сознательно: слияние не превращает каждый ребейз в 20-минутный прогон, а проверяет ровно то, что проверяет обычный пуш той же дельты. ### 4.3. Что видно в issue Один комментарий на слияние: `материал abc1234 · dev@def5678 → кандидат 0123abc · Validate <ссылка> зелёный · слито`. Комментарий #364 («dev продвинулся») остаётся — теперь он предваряет проверку, а не констатирует риск. ## 5. Единый manifest входов ### 5.1. Модуль `scripts/check-inputs.mjs` экспортирует `CHECKS` — по одной записи на проверку Validate: `frontend`, `backend`, `changed_mutants`, `integration` (hacs + hassfest), `smoke`, `golden`, `performance_smoke`, `docs`. Каждая запись — категории входов, каждая категория — список путей/предикатов: | категория | что это | примеры | |---|---|---| | `source` | код, который проверка исполняет или собирает | `src/**` для frontend/smoke/golden/perf; `custom_components/**/*.py` для backend/integration; `scripts/sh3d-convert/*.mjs`, `scripts/support-relay/**/*.py` для backend | | `tests` | сами тесты и их обёртки | `test/**`, `tests_backend/**`, `scripts/support-relay/tests/**`, `scripts/*-guard.mjs` | | `fixtures` | данные, которые тесты читают | `demo/fixtures/**`, `demo/golden/**` (сценарии и эталоны), `scripts/sh3d-convert/golden/**`, `scripts/config-schema.json` | | `config` | конфиги инструментов | `pyproject.toml`, `pytest.ini`, `tsconfig*.json`, `rollup.config.mjs`, `scripts/backend-coverage-baseline.txt`, `demo/performance/budgets-*.json` | | `toolchain` | что определяет среду и версии | `package.json`, `package-lock.json`, `tests_backend/requirements.txt`, сам `.github/workflows/validate.yml` (секция job — см. 5.4) | | `protocol` | харнес и протокол job | `demo/serve.mjs`, `demo/srv/**` (кроме `assets/` класса D), `demo/bundle-freshness.mjs`, `demo/*-runtime-compat.mjs`, `demo/guard/**`, `scripts/gate-reuse.mjs`, `scripts/classify-changes.mjs`, `scripts/check-inputs.mjs`, `scripts/mutation-gate.mjs` для `changed_mutants` | Функции: `inputsOf(check) → string[]` (развёрнутый по `git ls-files` список), `checksAffectedBy(files) → Set`, `coverage(files) → { covered, unknown }`. ### 5.2. Классификация из manifest `classify-changes.mjs` перестаёт держать свои регэкспы: выход `X = 'true'` ⇔ `checksAffectedBy(diff)` содержит X. Подклассификаторы `perf_iso` / `perf_interaction` остаются как есть (фильтры по `source` перф-смока). **Неизвестный исполняемый вход** — изменённый файл, который `coverage` не относит ни к одной проверке и который по расширению/пути исполняем (`*.mjs|*.js|*.ts|*.py|*.json|*.yml|*.yaml|*.html|*.toml|*.txt` под `scripts/`, `demo/`, `test/`, `tests_backend/`, `.github/`, `custom_components/`, `src/`) — включает **все** выходы (`classifyAll`) и пишет в summary «неизвестный вход: <файл> — расширен до полного набора». Документация (`docs/**`, `*.md` вне `docs`) и class-D копии бандла — известные *не*-входы, они не расширяют. ### 5.3. Ключ реюза из manifest `gate-reuse.mjs`: ключ job = `job:` + хеш содержимого файлов `inputsOf(job)` (канонизация переводов строк как сейчас). `HARNESS` и подмешивание `sourceFingerprint` уходят; `sourceFingerprint` остаётся у бандла и скриншотов (#245) — там его место. Следствие: `backend` перестаёт зависеть от `src/**`, но это включается **последним коммитом**, после того как §8.3 (полнота) зелёный, и с записью в CHANGELOG-у-инфры (`docs/TESTING.md`). Маркер падения (#386) и маркеры успеха не меняют формат — меняется только способ вычисления ключа, старые маркеры просто не совпадут один раз. ### 5.4. Секция job в toolchain Правка `validate.yml` меняет протокол только той job, чью секцию правят. `inputsOf` для категории `toolchain` включает `validate.yml` целиком — простое и честное решение: правка workflow гоняет всё. Отдельная «секция job» не вырезается (YAML-парсинг ради экономии одного прогона — лишняя сложность). ### 5.5. Полнота `test/check-inputs.test.mjs` держит **лист покрытия**: каждый файл из `git ls-files`, исполняемый по критерию §5.2, обязан входить в manifest хотя бы одной проверки или в явный список `NOT_AN_INPUT` (с причиной: `demo/docs/**` — съёмка документации вне Validate, `scripts/release-*.mjs` — релизные, `scripts/*.md`). Новый скрипт в `scripts/` без записи в manifest — красный тест, не тихое расширение. ## 6. Замыкание входов гарда мутанта ### 6.1. Обёртки объявляют входы Каждый `scripts/*-guard.mjs`, используемый в `guard:` реестра, экспортирует `export const GUARD_INPUTS = [...]` — файлы, которые он запускает или читает (для `backend-test-guard.mjs` — умолчание `tests_backend/test_ha_import_export.py` **плюс** третий аргумент, если передан). `guardInputs(guard)` = файлы-токены из строки (как `guardFiles`) ∪ `GUARD_INPUTS` каждой найденной обёртки (читаются статически регэкспом, без исполнения). Тест реестра: у каждой обёртки из гардов есть `GUARD_INPUTS`, и умолчание в коде совпадает с объявлением. ### 6.2. Импорты и фикстуры гарда Для каждого файла гарда `.mjs` — транзитивное замыкание относительных `import`/`export … from` внутри `test/`, `demo/`, `scripts/` (не `src/`, не `node_modules`); для `.py` — `from tests_backend.x import`, `import tests_backend.x` и всегда `tests_backend/conftest.py`. Фикстуры — строковые литералы путей `demo/fixtures/…`, `demo/golden/…`, `scripts/sh3d-convert/golden/…`, `test/fixtures/…` в файлах замыкания. Замыкание кэшируется на прогон (реестр — 582 мутанта, сотни гардов; чтение файлов один раз). ### 6.3. Отбор и отпечаток `selectChangedMutants` и `witnessFingerprint` используют `guardInputs` + замыкание §6.2. Отпечаток включает содержимое всех файлов замыкания — правка `test_trails.py` меняет отпечаток `vacuum-trail-*`, правка `serve.mjs` — всех смок-свидетелей. ### 6.4. Изменённые определения реестра Если дифф содержит `scripts/mutation-gate.mjs`, отбор дополнительно берёт мутантов, чьё определение (`id`, `guard`, `patches`, `because`) добавлено или изменено относительно базы: реестр базы читается через `git show :scripts/mutation-gate.mjs` во временный файл и импортируется как модуль (реестр — данные, побочных эффектов при импорте нет — это закреплено тестом). Удалённые мутанты — предупреждение в summary. Сторона патча остаётся точечной (не-скоуп): мутант, патчащий `a.ts`, не отбирается правкой `b.ts` — это зона полного прогона (ночь/кандидат), как и было. ## 7. Ночной прогон `nightly.yml` после `gh workflow run` находит запущенный прогон (по `workflow_dispatch`, ветке `dev`, времени ≥ момента запуска; до 3 мин ожидания появления) и `gh run watch --exit-status` с лимитом job 90 мин. Красный Validate → красный nightly; сообщение о падении в summary — ссылка на дочерний прогон. ## 8. Отрицательные тесты протокола 8.1. **Manifest**: для каждой проверки и каждой непустой категории — представительный реальный файл; тест подменяет его содержимое (через инъекцию `read` в функцию ключа) и утверждает: ключ реюза меняется, `checksAffectedBy` содержит проверку. Список представителей: `scripts/support-relay/relay.py`, `scripts/sh3d-convert/golden/*.json`, `scripts/config-schema.json`, `pyproject.toml`, `demo/serve.mjs`, `demo/srv/demo.html`, `demo/editor-runtime-compat.mjs`, `demo/golden/baselines/*.png`, `demo/performance/budgets-*.json`, `tests_backend/requirements.txt`, `.github/workflows/validate.yml`. **Обратная проба (AC6):** представитель `src/houseplan-card.ts` — ключ `backend` **не** меняется и `checksAffectedBy` **не** содержит `backend`; тест в `test/gate-reuse.test.mjs` и `test/classify-changes.test.mjs`, чтобы случайный возврат `sourceFingerprint` в ключ бэкенда был пойман, а не молча удлинял прогоны. 8.2. **Мутанты**: правка `tests_backend/test_ha_import_export.py` отбирает все 10 обёрток без третьего аргумента; правка `tests_backend/test_trails.py` отбирает `vacuum-trail-*` и меняет их отпечаток; правка `demo/serve.mjs` отбирает все смок-свидетели; дифф только по реестру с новым мутантом отбирает ровно его. 8.3. **Полнота**: лист покрытия §5.5 зелёный на текущем `git ls-files`. 8.4. **Слияние**: `decideMerge` — таблица случаев: `dev` не двигался → push; двигался, patch-id равен, Validate зелёный → push с lease; lease отклонён дважды → повтор, трижды → `S6`; patch-id отличается → `S7`; Validate красный → `S6`; прогон не найден → ошибка, не push. Плюс git-эксперимент аудита в temp-репозитории (`20 → 40`): скрипт требует Validate, не пушит. 8.5. **Мутанты на протокол** (реестр `mutation-gate.mjs`, каждый с отрицательным прогоном): `manifest-drops-protocol-category` (ключ без `protocol` → 8.1 падает), `classify-unknown-input-is-unaffected` (неизвестный вход не расширяет → 8.1 падает), `guard-inputs-ignore-wrapper-defaults` (→ 8.2 падает), `registry-diff-not-selected` (→ 8.2 падает), `merge-pushes-unvalidated-candidate` (→ 8.4 падает), `nightly-does-not-wait` — контрактный тест workflow (`test/nightly-workflow.test.mjs`) по образцу `validate-workflow.test.mjs`. ## 9. Совместимость и откат Маркеры реюза старого формата ключа не совпадают — один лишний полный прогон после слияния, дальше как обычно. `process.yml` читается конвейером из ветки по умолчанию — **зеркало в `main` только после слияния в `dev`** (правило #454). Откат — revert коммитов инфры; продуктового кода нет. ## 10. Критерии приёмки - AC1. При `dev`, продвинувшемся за время ревью, в `dev` попадает SHA, для которого есть зелёный Validate; без него слияния нет; `S8-merged` ставится только после push. - AC2. Повторное движение `dev` между Validate и push не приводит к силовой перезаписи: lease, повтор, лимит 3, затем `S6-in-progress` с комментарием. - AC3. Дифф с изменившимся patch-id после ребейза не сливается — задача в `S7-code-review`. - AC4. Ключ реюза каждой job вычисляется из manifest; для каждого представителя §8.1 доказано изменение ключа и классификации. - AC5. Неизвестный исполняемый вход расширяет прогон до полного набора и виден в summary; лист покрытия §5.5 зелёный. - AC6. `backend` не зависит от `src/**` — включено последним коммитом после AC4–AC5; доказано обратной пробой §8.1 (правка `src/houseplan-card.ts` не меняет ключ `backend` и не классифицируется как `backend`). - AC7. Обёртки гардов объявляют `GUARD_INPUTS`; отбор и отпечаток видят обёртки, импорты и фикстуры (§8.2). - AC8. Новый/изменённый мутант при диффе только по реестру отбирается и бежит. - AC9. `nightly.yml` красный при красном дочернем Validate. - AC10. Шесть мутантов §8.5 пойманы штатным раннером; `docs/TESTING.md` и `PROCESS.md` §10.3 описывают точный кандидат и manifest. ## 10.1. UX, модель данных, i18n Не затрагиваются. ## 10.2. Риски и меры - Ожидание Validate внутри ревью-job удлиняет слияние на 5–8 мин **только** когда `dev` двигался; лимит ожидания 45 мин с возвратом в `S6`, не зависанием. - Пуш кандидата в ветку PAT-ом запускает Validate — если PAT когда-нибудь заменят на `GITHUB_TOKEN`, прогон не стартует; шаг ждёт 3 мин и падает с явной ошибкой «прогон не появился», не с ложным зелёным. - Замыкание импортов расширяет отбор мутантов: правка `serve.mjs` отберёт все смок-свидетели (десятки минут в 3 шардах). Это цена честности; журнал #481 не смягчает — отпечаток тоже меняется. Оценить фактическое время на первом прогоне; при необходимости — четвёртый шард отдельным issue. - Лист покрытия начнёт краснеть на каждом новом скрипте — намеренно: запись в manifest занимает одну строку. ## 11. Release-артефакты Не user-visible. `docs/TESTING.md` (реюз, manifest, отбор мутантов), `PROCESS.md` §10.3 (слияние точного кандидата), зеркало `process.yml`/`nightly.yml` в `main` после слияния. ## 12. Затронутые файлы `scripts/check-inputs.mjs` (новый), `scripts/classify-changes.mjs`, `scripts/gate-reuse.mjs`, `scripts/mutation-gate.mjs` (guardInputs, замыкание, registry diff, шесть мутантов), `scripts/backend-test-guard.mjs`, `scripts/trail-resume-test-guard.mjs` (+ остальные обёртки — `GUARD_INPUTS`), `scripts/merge-candidate.mjs` (новый), `.github/workflows/process.yml`, `.github/workflows/nightly.yml`, `.github/workflows/validate.yml` (summary неизвестных входов), `test/check-inputs.test.mjs` (новый), `test/merge-candidate.test.mjs` (новый), `test/nightly-workflow.test.mjs` (новый), `test/gate-reuse.test.mjs`, `test/classify-changes.test.mjs`, `test/mutation-gate.test.mjs`, `docs/TESTING.md`, `PROCESS.md`. ## 12.1. Уточнение по реализации (S6) Категории §5.1 в коде не перечисляются руками: `CHECKS` объявляет у проверки корни (`roots`, что читается не из кода: `src/**` у сборки, `tests_backend/**` у pytest, `demo/golden/**` у эталонов, `validate.yml` у всех) и точки входа (`entries`), а source/tests/fixtures/config/protocol выводятся замыканием точек входа по импортам (транзитивно) и строковым путям (как листья). Это даёт те же представители §8.1, но без ручного списка, который отстаёт от кода. Реестр мутантов — лист замыкания (его гарды называют сотни путей как данные, не как зависимости); обёртки гардов объявляют `GUARD_INPUTS`, их собственные ссылки не читаются, чтобы явный аргумент отменял умолчание. ## 13. Принятые предположения - Validate на ветке (лёгкий набор + диффозависимые гейты) — достаточная «проверка нового дерева и затронутых взаимодействий» для слияния; тяжёлые гейты — за кандидатом релиза (#479). - `HP_PROCESS_TOKEN` остаётся PAT (пуш им запускает workflow). - Порядок реализации: §5 + §8.1/8.3 → §6 + §8.2 → §4 + §8.4 → §7 → AC6 последним. Один issue, одна ветка, одно ревью.