Files
houseplan-card/docs/specs/492-exact-candidate-and-input-manifest.md
T
2026-09-08 21:55:07 +00:00

32 KiB
Executable File
Raw Blame History

#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<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, одна ветка, одно ревью.