32 KiB
Executable File
#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. Скоуп
- Слияние точного кандидата (§4): проверка нового дерева перед push в
dev, безопасная обработка повторного движенияdev, равенство patch-id проверенного и сливаемого диффа. - Единый manifest входов (§5): один модуль объявляет входы каждой проверки по категориям source / tests / fixtures / config / toolchain / protocol; из него выводятся и классификация (
changes), и ключ реюза; неизвестный исполняемый вход расширяет проверки; лишняя зависимость бэкенда отsrc/**снимается после доказанной полноты. - Замыкание входов гарда мутанта (§6): обёртки объявляют свои входы, импорты и фикстуры гардов входят в отбор и отпечаток; изменённые/новые определения реестра отбираются явно.
- Ночной прогон ждёт результат (§7).
- Отрицательные тесты протокола (§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. Алгоритм
- Проверка #312 как сейчас:
actual=materialлибоmaterial+ один коммит документа ревью. devNow == materialBase→devне двигался,candidate == actual→ pushHEAD:devc--force-with-lease=refs/heads/dev:$devNow(fast-forward). Как сейчас, плюс lease.devдвигался → ребейз (конфликт — как сейчас,S6-in-progress). Сравнение patch-id: если дифф ветки после ребейза отличается от проверенного (контекст/содержимое патча изменились из-за соседних правок), кандидат публикуется в ветку, задача возвращается вS7-code-reviewс комментарием «дифф изменился при ребейзе, нужен новый заход ревью (§7.2)» — без слияния.- 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 мин — ошибка, не «зелёный»). - Validate красный →
S6-in-progress, комментарий с ссылкой на прогон: «кандидат после ребейза наdev@…красный». - Validate зелёный →
git push --force-with-lease=refs/heads/dev:$devNow candidate:dev. Lease отклонён (devдвинулся снова) →fetch, новая попытка с п.3; не более 3 попыток, после —S6-in-progressс комментарием «dev движется быстрее слияния, повторить». 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<check>, 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 <base>:scripts/mutation-gate.mjs во временный файл и импортируется как модуль (реестр — данные, побочных эффектов при импорте нет — это закреплено тестом). Удалённые мутанты — предупреждение в summary. Сторона патча остаётся точечной (не-скоуп): мутант, патчащий a.ts, не отбирается правкой b.ts — это зона полного прогона (ночь/кандидат), как и было.
7. Ночной прогон
nightly.yml после gh workflow run находит запущенный прогон (по workflow_dispatch, ветке dev, времени ≥ момента запуска; до 3 мин ожидания появления) и gh run watch <id> --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, одна ветка, одно ревью.