22 KiB
work_item, stage, author_agent, status, created_at, model_used
| work_item | stage | author_agent | status | created_at | model_used |
|---|---|---|---|---|---|
| ORCH-027 | architecture | architect | proposed | 2026-06-10 | claude-opus-4-8 |
ADR-001: Гейт покрытия тестами — edge sub-gate с ratchet-базовой линией
Work Item: ORCH-027 — детерминированный гейт покрытия тестами, блокирующий деградацию
покрытия перед слиянием ветки задачи в main.
Стадия: architecture
Сквозная регистрация: docs/architecture/adr/adr-0029-coverage-gate.md (решение
кросс-каттинговое — вводит новый QG check_coverage_gate, новый edge-под-гейт ребра
deploy-staging→deploy, новую аддитивную БД-таблицу coverage_baseline и новый артефакт
18-coverage-report.md).
Статус
Proposed
Контекст
Оркестратор ведёт автономную разработку: код пишет агент developer без человека-фильтра,
а на стадии testing агент tester сам решает, достаточно ли тестов. Существующие тестовые
гейты судят только по факту прохождения, не по полноте (сверено по коду):
check_ci_green(development → review) — exit-codepytest tests/в Gitea CI (.gitea/workflows/ci.yml); покрытие не меряется.check_tests_passed(testing → deploy-staging,qg/checks.py::_parse_tests_verdict) — читает machine-verdict LLM-tester'а из13-test-report.md, а не измеренную метрику.- Merge-gate re-test (ORCH-043,
src/merge_gate.py) — повторныйpytestна догнанной ветке, снова только exit-code.
Ни один гейт не замечает «300 строк кода, 0 тестов» или багфикс без регрессионного теста. При пакетном автономном прогоне (ORCH-088, «10–20 задач за ночь») это означает монотонную деградацию покрытия: каждая задача срезает угол на тестах, и за десятки задач проект тихо теряет тестируемость. Нужна детерминированная метрика вместо доверия суждению агента — по духу аналогично security-гейту (ORCH-022, adr-0012).
Требования (01-brd.md/02-trz.md/03-acceptance-criteria.md): измерять покрытие
инструментально перед merge в main (BR-1/FR-1); блокировать деградацию относительно
абсолютного порога и/или базовой линии (BR-2/BR-3/FR-2); хранить и наращивать базовую линию
(ratchet, FR-4); kill-switch + per-repo область, нулевая регрессия для enduro-trails
(BR-4/FR-5); fail-open по умолчанию при сбое инструмента (NFR-2/FR-6); never-raise и
self-hosting-безопасность (NFR-1/NFR-3); неизменность существующих контрактов (NFR-5).
Решение
Сводка
Вводим детерминированный (без LLM) гейт покрытия как под-гейт ребра
deploy-staging → deploy — рядом с security-gate (ORCH-022), merge-gate (ORCH-043) и
image-freshness (ORCH-058), исполняемый ПОСЛЕ merge-gate и ДО image-freshness.
STAGE_TRANSITIONS не меняется; в QG_CHECKS добавляется check_coverage_gate. Паттерн —
1:1 как у соседних под-гейтов: leaf-модуль src/coverage_gate.py (never-raise) + тонкая
обёртка в QG_CHECKS + врезка _handle_coverage_gate в advance_stage. Базовая линия main
хранится в аддитивной БД-таблице coverage_baseline и наращивается вверх (ratchet) в
choke-point подтверждённого merge _handle_merge_verify (ребро deploy → done). Вердикт
пишется в артефакт 18-coverage-report.md (frontmatter-ключ coverage_status:) и читается
обратно из того же файла (single source of truth, как security_status:).
D1 — Точка в конвейере: edge sub-gate deploy-staging → deploy, ПОСЛЕ merge-gate (FR-3a)
Из трёх кандидатов TRZ FR-3 выбран (a) edge sub-gate на ребре deploy-staging → deploy
(advance_stage, src/stage_engine.py, блок current_stage == "deploy-staging"). Это даёт
структурную гарантию «гейт ДО merge в main» (merge выполняется детерминированным merge-актором
в _handle_merge_verify на ребре deploy → done), детерминизм и владение исходом на
вмешательстве — полное соответствие NFR-3/NFR-6.
Порядок среди под-гейтов: security → merge → coverage → image-freshness. Обоснование:
- ПОСЛЕ merge-gate (а не первым, как security). Merge-gate выполняет догон ветки на свежий
origin/main(auto_rebase_onto_mainпод merge-lease, ORCH-043/026). Покрытие имеет смысл мерить на догнанном HEAD — это ровно тот код, что landed вmain; измерение до rebase показало бы покрытие устаревшей базы. Поэтому coverage обязан идти после merge-gate (в отличие от security, который специально фейлит дёшево ДО rebase). - ДО image-freshness. Прогон pytest под coverage дорог, но дешевле полного docker-rebuild staging-образа. Фейлить покрытие до rebuild — экономия (паттерн «fail before expensive rebuild», 07-infra security-гейта).
- Merge-lease held на этой точке. Merge-gate уже захватил merge-lease (ORCH-043). Значит
FAIL coverage обязан освободить merge-lease при откате — как делает image-freshness
rollback (
merge_gate.release_merge_lease,stage_engine.py:1165), и в отличие от security-gate rollback (тот идёт ДО захвата lease и lease не трогает). Это явный инвариант реализации (TR-2).
Привязка: BR-2/FR-3/AC-2; NFR-3/AC-7.
D2 — Измеритель: pytest-cov (coverage.py), --cov=src (FR-1, BR-6)
В requirements.txt добавляется pytest-cov (плагин-обёртка над coverage.py). Измерение —
прогон python -m pytest tests/ --cov=src --cov-report=json:<tmp>/coverage.json --cov-report= в изолированном per-branch worktree (ensure_worktree, прецедент
check_tests_local/merge-gate re-test). Числовая метрика — totals.percent_covered из JSON
(line coverage, %). Скоуп измерения — src/ (не tests/: покрытие самих тестов вне
объёма, BRD §«Вне объёма»). Сеть при измерении не нужна. Тайм-аут — coverage_run_timeout_s
(по образцу merge_retest_timeout_s/security_scan_timeout_s).
Стек-расширяемость (BR-6/AC-… BR-6): измеритель инкапсулирован за функцией
measure_coverage(repo, branch) -> float | None; чистая логика решения
compute_coverage_verdict(...) и хранилище базовой линии не зависят от Python/pytest.
Добавление jest/jacoco-измерителя для будущего стека — новая ветка measure_*, без переписывания
ядра. Фактическая интеграция не-Python стеков — вне объёма ORCH-027.
D3 — Чистая функция решения (FR-2, NFR-6, BR-2/BR-3)
compute_coverage_verdict(measured, baseline, floor, policy, epsilon) -> (ok: bool, reason: str) —
детерминированная чистая функция (без LLM, без I/O):
policy = "absolute"→ PASS ⇔measured >= floor - epsilon.policy = "baseline"→ PASS ⇔measured >= baseline - epsilon.policy = "both"(дефолт) → PASS ⇔ выполнены оба условия.baseline is None(нет сохранённой базовой линии) → baseline-условие не применяется (bootstrap: нельзя регрессировать против пустоты) → решает только absolute-часть; измеренное значение засеет базовую линию при merge (D5).epsilon— малый неотрицательный допуск на шум измерения (NFR-4/AC-4): дрожание ±доли процента у границы не заворачивает задачу.
FAIL → штатный откат на development + инкремент общего _developer_retry_count (cap
MAX_DEVELOPER_RETRIES, затем set_issue_blocked + Telegram) — точно как security/merge-gate
rollback. Дословный reason (измеренное/порог/базовая линия/дельта) встраивается в task_desc
developer'а (паттерн ORCH-046). Привязка: AC-2/AC-3.
D4 — Хранилище базовой линии: аддитивная БД-таблица coverage_baseline (FR-4, NFR-5)
Базовая линия main хранится в БД, не в файле репозитория:
CREATE TABLE IF NOT EXISTS coverage_baseline (
repo TEXT PRIMARY KEY,
coverage REAL NOT NULL,
source_sha TEXT,
updated_at TEXT NOT NULL
);
(паттерн repo_freeze/job_deps — CREATE TABLE IF NOT EXISTS, существующие таблицы не
мигрируются, NFR-5/AC-8; детали — 08-data-requirements.md). Почему БД, а не файл в репо
(.coverage-baseline.json): файл пришлось бы коммитить в main на каждый ratchet → git-churn,
сам файл попадает в diff и может конфликтовать при параллельных merge, плюс он часть измеряемого
дерева. БД-таблица — restart-safe, аддитивна, обновляется атомарно и не порождает коммитов.
Таблица keyed by repo → общая прод-БД (self-hosting) безопасно разделяет базовые линии разных
репозиториев.
D5 — Ratchet-up в choke-point подтверждённого merge (FR-4, BR-3)
Базовая линия наращивается только вверх и только при подтверждённом слиянии в main.
Единственный авторитетный choke-point подтверждённого merge — _handle_merge_verify (ребро
deploy → done, ORCH-071/073, доказательство SHA-in-main). Туда добавляется never-raise врезка
coverage_gate.ratchet_baseline_on_merge(repo, work_item_id, branch, sha), вызываемая после
того как merge подтверждён (_handle_merge_verify вернул False = confirmed) и до перехода
в done:
- Читает измеренное покрытие смёрженной ветки из артефакта
18-coverage-report.md(single source of truth — то же значение, что гейт записал на ребреdeploy-staging→deploy). - Атомарный compare-and-set:
UPDATE coverage_baseline SET coverage=?, source_sha=?, updated_at=? WHERE repo=? AND coverage <= ?(илиINSERTпри отсутствии строки — bootstrap). Условиеcoverage <= measuredгарантирует, что базовая линия никогда не падает (FR-4), даже при гонке.
Сериализация (анти-гонка, NFR-5/AC-4): на этой точке merge-lease ещё held (release на
done/rollback, stage_engine.py:446), а merge репо сериализован per-repo (ORCH-043). Плюс
атомарный compare-and-set в SQL — двойная защита: даже без lease два параллельных merge не
понизят и не потеряют значение. Bootstrap — первый merge применимого репо засевает базовую линию
своим измеренным покрытием.
D6 — Условность, kill-switch, наблюдаемость (FR-5/FR-7, BR-4/BR-5)
- Флаги (
config.py, envORCH_COVERAGE_*):coverage_gate_enabled(bool, kill-switch),coverage_gate_repos(CSV; пусто → только self-hostingis_self_hosting_repo, по образцуmerge_gate/security_gate/image_freshness),coverage_min_percent(float, абсолютный порог-floor),coverage_policy(absolute|baseline|both, дефолтboth),coverage_epsilon(float, дефолт малый, напр.0.5),coverage_tool_fail_closed(bool, дефолтFalse),coverage_run_timeout_s(int). applies(repo)(локальная проверка) выполняется ПЕРВОЙ; дорогой прогон измерения — только приapplies==True. Вне области → no-op(True, "Coverage gate N/A")(прецедентcheck_staging_statusдля не-self, ORCH-035). Приcoverage_gate_enabled=False— гейт инертен, конвейер 1:1 как до ORCH-027 (AC-5).- FR-6 (ошибка инструмента):
measure_coverageвернулNone(инструмент упал/недоступен/ метрика не распарсилась) → по умолчанию fail-open + WARNING (observability-строка),coverage_tool_fail_closed=True→ fail-closed (FAIL). Дефолт анти-петля (прецедент ORCH-061/ORCH-022 dep-audit), чтобы инфра-сбой не заклинил автономный конвейер. - FR-7 (наблюдаемость): артефакт
18-coverage-report.md(frontmattercoverage_status: PASS|FAIL+measured_coverage/baseline/floor/policy/delta); read-only блокcoverageвGET /queue(src/main.py); при FAIL —send_telegramс кликабельным номером (plane_issue_link/link_for), измеренным покрытием, порогом/базовой линией и дельтой.
D7 — Машинный вердикт и парсинг (§6 ТЗ, AC-9)
Гейт сам вычисляет вердикт (как check_security_gate) и пишет
18-coverage-report.md с YAML-frontmatter coverage_status: (PASS | FAIL); регистр
чувствителен, имя фиксируется. Чтение обратно — через единый контракт src/frontmatter.py
(parse_frontmatter/read_frontmatter_value, ORCH-052c), как все вердикт-парсеры. Артефакт
несёт аддитивно обязательную 6-польную схему 52c, не трогая coverage_status:. В QG_CHECKS
добавляется check_coverage_gate (тонкая обёртка, делегирующая в leaf); семантика и состав
существующих check_* / machine-verdict ключей (verdict:/result:/deploy_status:/
staging_status:/security_status:) — байт-в-байт прежние (NFR-5/AC-8).
D8 — Опциональный override базовой линии (FR-4 / §4 API)
Для легитимного разового снижения покрытия (напр. удаление большого протестированного модуля)
вводится опциональный ручной эндпоинт POST /coverage/baseline?repo=<repo>&value=<float> (по
образцу POST /serial-gate/unfreeze) — устанавливает/сбрасывает базовую линию вручную.
Альтернатива без эндпоинта — временно переключить coverage_policy=absolute. Эндпоинт
рекомендован для эксплуатационной гибкости, но не критичен для v1.
Альтернативы
- Точка измерения — CI-job (
check_ci_green, FR-3c). Пороги/политика/базовая линия/артефакт плохо выражаются статусом коммита; ratchet требует записи в общую БД, недоступную из CI-раннера чисто. Коуплинг с раннером. Отклонено для v1 (точка расширения), как у security-гейта. - Точка измерения —
testing → deploy-staging(рядом сcheck_tests_passed, FR-3b). Ветка ещё не догнана на свежийmain→ измеренное покрытие может не соответствовать landed-коду; откат отсюда не освобождает merge-lease иначе. Edgedeploy-staging→deployпосле merge-gate — точнее. Отклонено. - Базовая линия в файле репо (
.coverage-baseline.json). Git-churn на каждый ratchet, конфликты при параллельных merge, файл — часть измеряемого дерева. Отклонено в пользу аддитивной БД-таблицы (D4). - Складывание измерения в merge-gate re-test (один pytest-прогон). Снижает дабл-ран, но коуплит coverage-логику с merge_gate; нарушает leaf-изоляцию ТЗ. Отклонено для v1 (возможный follow-up — измерять покрытие в том же прогоне).
- Новый stage
coverage. «Пустая» стадия без агента не имеет триггера (как в ORCH-043/022). Отклонено. - Жёсткий абсолютный порог без baseline/epsilon. Массовые ложные заворота → петля rework.
Отклонено в пользу консервативного
both+ epsilon (NFR-4).
Последствия
- + Класс «тихо просевшее покрытие» закрыт детерминированной метрикой; защита от монотонной деградации в пакетном автономном прогоне (ORCH-088). Базовая линия может только расти (ratchet).
- + Нулевая регрессия: при выключенном флаге / вне области (enduro-trails) — конвейер
байт-в-байт прежний;
STAGE_TRANSITIONS/QG_CHECKS-семантика/вердикт-ключи не тронуты. - + Self-hosting-безопасно: гейт только мерит/читает/пишет/решает; не деплоит, не рестартит
прод, не пушит/форс-пушит в
main(NFR-3). - − Дополнительный прогон pytest под coverage на каждой применимой задаче (после merge-gate
re-test) → ещё один полный тест-ран. Митигейшн: ограничен
coverage_run_timeout_s; фейлит до дорогого image-rebuild; follow-up — слияние с merge-gate re-test. - − Ещё один «скрытый» под-гейт ребра (нет в
STAGE_TRANSITIONS); новая pip-зависимость (pytest-cov); v1 — Python-only (мульти-стек — точка расширения BR-6). - − Дефолтный fail-open означает, что устойчивый сбой инструмента тихо пропускает задачи
(с WARNING). Митигейшн: громкий лог + переключатель
coverage_tool_fail_closed. - Сквозное изменение (новый QG + edge-под-гейт + новая БД-таблица + новый артефакт) →
лейбл
arch:major-change; прод-деплой ORCH-027 — строго через staging-гейт (8501), без рестарта прод-контейнера. - Откат:
coverage_gate_enabled=False→ полный no-op (мгновенный обратимый kill-switch). Полное удаление — снять врезки_handle_coverage_gate/ratchet_baseline_on_merge, удалить leaf-модуль,check_coverage_gateизQG_CHECKS, флаги, артефакт-шаблон; таблицаcoverage_baselineаддитивна и может остаться (инертна).
Ссылки
- BRD:
docs/work-items/ORCH-027/01-brd.md - TRZ:
docs/work-items/ORCH-027/02-trz.md - Acceptance:
docs/work-items/ORCH-027/03-acceptance-criteria.md - Data:
docs/work-items/ORCH-027/08-data-requirements.md - Risks:
docs/work-items/ORCH-027/10-tech-risks.md - Сквозной ADR:
docs/architecture/adr/adr-0029-coverage-gate.md - Сверено по коду:
src/stage_engine.py(_handle_security_gate/_handle_merge_gate/_handle_image_freshness/_handle_merge_verify),src/security_gate.py,src/merge_gate.py,src/qg/checks.py,.gitea/workflows/ci.yml,pytest.ini - Прецеденты: adr-0012 (security-гейт), adr-0006 (merge-gate — edge-под-гейт/откат/lease),
adr-0008 (image-freshness — условность/fail-closed), adr-0003 (
is_self_hosting_repo), adr-0009 (анти-петля ложных FAIL), adr-0014 (SHA-in-main как source of truth для merge)