Files
orchestrator/docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md

22 KiB
Raw Permalink Blame History

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-code pytest 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, «1020 задач за ночь») это означает монотонную деградацию покрытия: каждая задача срезает угол на тестах, и за десятки задач проект тихо теряет тестируемость. Нужна детерминированная метрика вместо доверия суждению агента — по духу аналогично 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_depsCREATE 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:

  1. Читает измеренное покрытие смёрженной ветки из артефакта 18-coverage-report.md (single source of truth — то же значение, что гейт записал на ребре deploy-staging→deploy).
  2. Атомарный 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, env ORCH_COVERAGE_*): coverage_gate_enabled (bool, kill-switch), coverage_gate_repos (CSV; пусто → только self-hosting is_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 (frontmatter coverage_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 иначе. Edge deploy-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)