diff --git a/.env.example b/.env.example index 90c59c4..b422fbd 100644 --- a/.env.example +++ b/.env.example @@ -372,6 +372,28 @@ ORCH_SECURITY_SCAN_TIMEOUT_S=300 ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED=false ORCH_SECURITY_SECRETS_BLOCK=true +# ORCH-027: coverage-gate (deterministic test-coverage) on the deploy-staging -> +# deploy edge, run AFTER the merge-gate and BEFORE image-freshness. Measures line +# coverage of src/ with pytest-cov in the per-branch worktree, compares to an absolute +# floor and/or the ratchet baseline of `main`; FAIL -> rollback to development + +# developer-retry (cap 3). Verdict in the 18-coverage-report.md frontmatter +# (coverage_status:). See ADR-001-coverage-gate.md. +# GATE_ENABLED -> global kill-switch; false -> pipeline 1:1 as before ORCH-027. +# GATE_REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting. +# MIN_PERCENT -> absolute floor (% line coverage) for policy absolute/both. +# POLICY -> absolute | baseline | both (default both). +# EPSILON -> noise tolerance (%) at the boundary (anti-flap). +# TOOL_FAIL_CLOSED -> strict mode: a coverage-tool error -> FAIL instead of the +# default fail-open + warning (anti-loop). Default false. +# RUN_TIMEOUT_S -> wall-clock budget for the pytest --cov run. +ORCH_COVERAGE_GATE_ENABLED=true +ORCH_COVERAGE_GATE_REPOS= +ORCH_COVERAGE_MIN_PERCENT=0.0 +ORCH_COVERAGE_POLICY=both +ORCH_COVERAGE_EPSILON=0.5 +ORCH_COVERAGE_TOOL_FAIL_CLOSED=false +ORCH_COVERAGE_RUN_TIMEOUT_S=900 + # ORCH-021: post-deploy production monitoring + degradation reaction. After the # terminal deploy->done transition for an applicable repo, a reserved-agent job # `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae9791..d218e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Детерминированный гейт покрытия тестами — защита от тихой деградации coverage перед merge в `main`** (ORCH-027, `feat`): существующие тестовые гейты (`check_ci_green`, `check_tests_passed`, merge-gate re-test) судят только по **факту** прохождения, не по **полноте** — ни один не замечает «300 строк кода, 0 тестов», и при пакетном автономном прогоне (ORCH-088) покрытие монотонно деградирует. Введён детерминированный (без LLM) под-гейт ребра `deploy-staging → deploy` по образцу security-гейта (ORCH-022): leaf `src/coverage_gate.py` (never-raise) + тонкая обёртка `check_coverage_gate` в `QG_CHECKS` + врезка `_handle_coverage_gate` в `advance_stage`. **Аддитивно:** `STAGE_TRANSITIONS` / семантика существующих `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) — байт-в-байт прежние; новая БД-таблица аддитивна (NFR-5/AC-8). См. `docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md`, сквозной `docs/architecture/adr/adr-0029-coverage-gate.md`. + - **Точка/порядок (D1, AC-2):** под-гейт исполняется **ПОСЛЕ merge-gate** (покрытие меряется на догнанном `auto_rebase_onto_main` HEAD — ровно том коде, что landed в `main`) и **ДО image-freshness** (фейл до дорогого docker-rebuild). FAIL → штатный откат на `development` (+ инкремент developer-retry, cap `MAX_DEVELOPER_RETRIES`) **и освобождение merge-lease** (merge-gate держал его на своём PASS — зеркало image-freshness rollback, TR-2). `STAGE_TRANSITIONS` не меняется (под-гейт, как security/merge/image-freshness). + - **Измерение (D2, FR-1/AC-1):** `python -m pytest tests/ --cov=src --cov-report=json` в изолированном per-branch worktree (`ensure_worktree`, прецедент `check_tests_local`); метрика — `totals.percent_covered` (line coverage `src/`). Измеритель инкапсулирован за `measure_coverage(repo, branch) -> float | None` (стек-расширяемость BR-6: jest/jacoco — новая ветка `measure_*`, без переписывания ядра). Тайм-аут `coverage_run_timeout_s`. Новая pip-зависимость `pytest-cov==5.0.0` (offline на момент замера). + - **Чистая функция решения (D3, FR-2/AC-3):** `compute_coverage_verdict(measured, baseline, floor, policy, epsilon) -> (ok, reason)` — детерминированная, без LLM/IO. `absolute` → `measured ≥ floor−ε`; `baseline` → `measured ≥ baseline−ε`; `both` (дефолт) → оба; `baseline is None` (bootstrap) → baseline-условие не применяется (нельзя регрессировать против пустоты). `epsilon` — допуск на шум измерения (NFR-4, анти-флап у границы). Покрыто unit-тестами всех режимов/границ/epsilon. + - **Базовая линия + ratchet (D4/D5, FR-4/AC-4):** аддитивная БД-таблица `coverage_baseline(repo PK, coverage, source_sha, updated_at)` (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/`job_deps`; существующие таблицы не мигрируются). Хелперы `db.get_coverage_baseline`/`ratchet_coverage_baseline`/`set_coverage_baseline`/`all_coverage_baselines`. Наращивание **только вверх** в choke-point подтверждённого merge `_handle_merge_verify` (ребро `deploy → done`): `coverage_gate.ratchet_baseline_on_merge` читает измеренное из `18-coverage-report.md` (single source of truth) и применяет **атомарный compare-and-set** `UPDATE … WHERE coverage <= measured` (или `INSERT` — bootstrap) под держимым merge-lease (ORCH-043) → базовая линия никогда не падает даже при гонке. Меньшее значение базовую линию не понижает. + - **Условность + fail-open (D6, FR-5/FR-6/AC-5/AC-6):** `coverage_gate_applies(repo)` (локально) ПЕРВЫМ — дорогой прогон только при `applies==True`. `coverage_gate_enabled=False` → инертно (1:1 как до ORCH-027); `coverage_gate_repos` (CSV; **пусто → self-hosting only** `is_self_hosting_repo`, как security/merge/image-freshness) → enduro-trails не затронут (no-op `(True, "N/A")`). Ошибка/недоступность coverage-инструмента или непарсимая метрика → **fail-open + WARNING** по умолчанию (`coverage_tool_fail_closed=False`, анти-петля по образцу ORCH-061/022 dep-audit); флаг переключает в fail-closed. + - **Машинный вердикт + наблюдаемость (D7/D8, FR-7/AC-9):** артефакт `18-coverage-report.md` (frontmatter `coverage_status: PASS|FAIL` + `measured_coverage`/`baseline`/`floor`/`policy`/`epsilon`/`delta`), вердикт читается ТОЛЬКО из frontmatter через `src/frontmatter.parse_frontmatter` (ORCH-052c, регистр фиксирован); гейт сам пишет отчёт и читает вердикт обратно из того же файла (single source of truth, как `security_status:`). Read-only блок `coverage` в `GET /queue` (kill-switch/scope/policy/floor/epsilon/per-repo baselines). При FAIL — `send_telegram` с кликабельным номером (`link_for`), измеренным покрытием, порогом/базовой линией и дельтой. Опциональный ручной override `POST /coverage/baseline?repo=…&value=…` (по образцу `POST /serial-gate/unfreeze`) для легитимного разового снижения покрытия. + - **Self-hosting безопасность (NFR-1/NFR-3/AC-7):** leaf не импортирует `stage_engine`; любое исключение перехвачено (never-raise); гейт только мерит/читает/пишет/решает — не деплоит, не рестартит прод-контейнер, не пушит/форс-пушит `main` (структурно проверено AST-тестом TC-12). Прод-деплой ORCH-027 — строго через staging-гейт (8501), без рестарта прод-контейнера (лейбл `arch:major-change`). + - **Флаги (`config.py`, env `ORCH_COVERAGE_*`, `.env.example`):** `coverage_gate_enabled` (kill-switch), `coverage_gate_repos`, `coverage_min_percent` (дефолт 0.0 — безопасный раскат: no-regression ведёт ratchet-базовая линия, floor не фейлит в день один), `coverage_policy` (дефолт `both`), `coverage_epsilon` (0.5), `coverage_tool_fail_closed` (False), `coverage_run_timeout_s` (900). Откат: `ORCH_COVERAGE_GATE_ENABLED=false` → полный no-op (мгновенный обратимый kill-switch). + - **Инфра-предусловие:** добавить `pytest-cov` в прод/staging-образ (`requirements.txt`). При первом применимом merge базовая линия засевается фактическим покрытием `main` (bootstrap). Тесты: `tests/test_coverage_gate.py` (TC-01…TC-15: режимы/границы/epsilon verdict, ratchet up-only + bootstrap + per-repo изоляция, applies/kill-switch, fail-open/closed, never-raise, write/read-back отчёта, self-hosting AST-safety, интеграция в `advance_stage` с откатом+release lease, реальное измерение pytest-cov на фикстур-репо + тайм-аут, snapshot + неизменность `QG_CHECKS`/`STAGE_TRANSITIONS`). Обновлены анти-регресс-реестры `QG_CHECKS` (`test_config`/`test_plane_status_model`/`test_qg_registry_snapshot`/`test_stages_invariants`) и edge-тесты `test_stage_engine` (`check_coverage_gate: _pass`). Полный регресс `tests/ -q` зелёный. - **Live-карточка трекера: HTML-инъекция «<1м» больше не застывает карточку — экранирование всех данных-полей на границе рендера** (ORCH-095, `fix`): карточка задачи (`src/notifications.py::render_task_tracker`) шлётся/редактируется с `parse_mode=HTML`. `_fmt_minutes` для стадии < 60 с возвращает литерал `"<1м"`, который интерполировался в HTML-текст **сырым** → Telegram парсит `<1м` как открывающий тег → `editMessageText` отвечает `400 can't parse entities: Unsupported start tag "1м"` → `edit_telegram` классифицирует как `EDIT_FAILED` → `update_task_tracker` делает ранний `return` (анти-дубль ORCH-087) → **карточка застывает** (детерминированно воспроизведено 09.06 на ORCH-093, `message_id 18854`). Корневой класс шире одного `<1м`: все подставляемые **данные** (длительности, статус-лейбл, модель, эффорт, токены/стоимость) вставлялись сырыми; экранирован был только заголовок (`esc_title`) и href/label внутри `plane_issue_link`. **Аддитивно, never-raise, без нового поведения конвейера:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / транспорт нотификаций / схема БД — **не тронуты** (затронут ровно один модуль индикативного слоя); kill-switch не требуется (исправление дефекта корректности, откат = `git revert`). - **Экранирование на границе рендера, не в источнике (ADR-001 D1/D2, AC-1/AC-2):** новый модуль-локальный хелпер `_esc(x) = html.escape(str(x))` (never-raise → `""` на исключении) оборачивает каждое подставляемое **данные-значение** (категория D) ровно один раз в точке интерполяции в `render_task_tracker`/`_stage_line`: длительности (`_fmt_minutes`/`_capped_review_str`), статус-лейбл (`_card_status_label`), модель (`short_model_name`), эффорт (`_run_effort`), токены/стоимость (`fmt_tokens`/`fmt_cost`). Функции-источники остаются **HTML-агностичными** (данные, не разметка): `src/usage.py` и `_fmt_minutes` не тронуты — `_fmt_minutes` продолжает возвращать `"<1м"`, безопасность даёт escape на границе (`<1м` рендерится оператору визуально идентично `<1м` → видимый формат не меняется). - **Категория M (намеренная разметка) неприкосновенна (D5, AC-3):** кликабельный номер задачи `num_html` (`plane_issue_link`, внутри уже экранированы href+label), `link_for(...)` в строке «⏳ ждёт …», `_done_link(...)` («🔗 PR #n · 📦 Внедрено») и уже-экранированный `esc_title` через `_esc` **не** проходят → остаются валидным HTML, номер остаётся кликабельным. Двойное экранирование (`&lt;`) структурно исключено: D-слот → `_esc` ровно один раз, M-слот → as-is. diff --git a/CLAUDE.md b/CLAUDE.md index 0035cb0..6181f95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,51 @@ created → analysis → architecture → development → review → testing → `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, `docs/architecture/adr/adr-0026-stop-cancel-task.md`. +## Гейт покрытия тестами (ORCH-027) +Существующие тестовые гейты (`check_ci_green`, `check_tests_passed`, merge-gate re-test) судят +только по **факту** прохождения, не по **полноте** — ни один не замечает «300 строк кода, 0 +тестов», и при пакетном автономном прогоне (ORCH-088) покрытие монотонно деградирует. Введён +**детерминированный (без LLM) под-гейт ребра `deploy-staging → deploy`** по образцу security-гейта +(ORCH-022): leaf `src/coverage_gate.py` (never-raise) + тонкая обёртка `check_coverage_gate` в +`QG_CHECKS` + врезка `_handle_coverage_gate` в `advance_stage`. **Инвариант:** `STAGE_TRANSITIONS` / +семантика существующих `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/ +`staging_status:`/`security_status:`) — байт-в-байт прежние; новая БД-таблица аддитивна (NFR-5). +- **Точка/порядок:** **ПОСЛЕ merge-gate** (покрытие меряется на догнанном `auto_rebase_onto_main` + HEAD — ровно том коде, что landed в `main`) и **ДО image-freshness** (фейл до дорогого + docker-rebuild). Порядок под-гейтов: **security → merge → coverage → image-freshness.** FAIL → + штатный откат на `development` (+ инкремент developer-retry, cap `MAX_DEVELOPER_RETRIES`) **и + освобождение merge-lease** (merge-gate держал его на своём PASS — зеркало image-freshness rollback). +- **Измерение:** `python -m pytest tests/ --cov=src --cov-report=json` в изолированном per-branch + worktree (`ensure_worktree`); метрика — `totals.percent_covered` (line coverage `src/`). Измеритель + за `measure_coverage(repo, branch) -> float | None` (стек-расширяемость BR-6). Тайм-аут + `coverage_run_timeout_s`. Новая pip-зависимость `pytest-cov`. +- **Решение — чистая функция** `compute_coverage_verdict(measured, baseline, floor, policy, epsilon) + -> (ok, reason)`: `absolute` → `measured ≥ floor−ε`; `baseline` → `measured ≥ baseline−ε`; `both` + (дефолт) → оба; `baseline is None` (bootstrap) → baseline-условие не применяется. `epsilon` — + допуск на шум измерения (анти-флап у границы). +- **Базовая линия — аддитивная БД-таблица** `coverage_baseline(repo PK, coverage, source_sha, + updated_at)` (`CREATE TABLE IF NOT EXISTS`; хелперы `db.get_coverage_baseline`/ + `ratchet_coverage_baseline`/`set_coverage_baseline`). Наращивание **только вверх** в choke-point + подтверждённого merge `_handle_merge_verify` (ребро `deploy → done`): `ratchet_baseline_on_merge` + читает измеренное из `18-coverage-report.md` (single source of truth), атомарный compare-and-set + `UPDATE … WHERE coverage <= measured` под держимым merge-lease (ORCH-043) → базовая линия не падает + даже при гонке; bootstrap засевается первым применимым merge. +- **Условность (как ORCH-22/43/58):** `coverage_gate_enabled` (kill-switch; `False` → 1:1 как до + ORCH-027) + `coverage_gate_repos` (CSV; **пусто → self-hosting only** `is_self_hosting_repo` → + enduro не затронут, no-op `(True, "N/A")`); `applies(repo)` (локально) ПЕРВЫМ — дорогой прогон + только при `applies==True`. Ошибка инструмента/непарсимая метрика → **fail-open + WARNING** по + умолчанию (`coverage_tool_fail_closed=False`, анти-петля); флаг → fail-closed. +- **Артефакт `18-coverage-report.md`** (frontmatter `coverage_status: PASS|FAIL` + + `measured_coverage`/`baseline`/`floor`/`policy`/`epsilon`/`delta`), вердикт читается ТОЛЬКО из + frontmatter через `src/frontmatter.py` (single source of truth, как `security_status:`). + Наблюдаемость — read-only блок `coverage` в `GET /queue`; при FAIL — `send_telegram` с кликабельным + номером, измеренным/порогом/дельтой; опциональный ручной override `POST /coverage/baseline`. + Флаги `ORCH_COVERAGE_*` (`MIN_PERCENT`/`POLICY`/`EPSILON`/`TOOL_FAIL_CLOSED`/`RUN_TIMEOUT_S`). + Self-hosting-безопасно: гейт только мерит/читает/пишет/решает — не деплоит/не рестартит прод/не + пушит `main`. **Инфра-предусловие:** `pytest-cov` в прод/staging-образе. Детали — + `docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md`, + `docs/architecture/adr/adr-0029-coverage-gate.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` @@ -162,7 +207,7 @@ created → analysis → architecture → development → review → testing → - Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_status:`), никогда проза. **ORCH-52c (ORCH-076):** парсинг frontmatter сведён к единому контракту `src/frontmatter.py` (reader `read_frontmatter_value` — BC; единый парс-примитив `parse_frontmatter`; writer `render/write_frontmatter`; валидатор схемы `validate_schema`/`REQUIRED_FIELDS` — warning-only по умолчанию, hard-fail только под kill-switch `frontmatter_validation_strict`, дефолт `False`). Пять вердикт-парсеров (`check_reviewer_verdict`, `_parse_tests_verdict`, `_parse_deploy_status`, `_parse_staging_status`, `parse_security_status`) читают через ОДНУ точку парсинга; семантика вердиктов и `STAGE_TRANSITIONS`/состав `QG_CHECKS` — 1:1. Формальная спека «стадия → обязательный выход» + обязательная frontmatter-схема — `docs/_standards/HANDOFF_PROTOCOL.md` ## Артефакты задачи (`docs/work-items//`) -`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022). +`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022), `18-coverage-report.md` (coverage-гейт: `coverage_status:`/measured/baseline, ORCH-027). **Стандарт документов (ORCH-075, ORCH-52b):** структура каждого дока, карта «стадия→агент→документ→гейт→machine-key» и конвенция ADR-naming зафиксированы в `docs/_standards/PIPELINE_DOCS.md` (golden source); копируемые скелеты — в `docs/_templates/`. Перед написанием номерного дока бери скелет из `docs/_templates/` и не меняй имя machine-key frontmatter (регистр чувствителен — иначе гейт упадёт ложно). diff --git a/docs/_standards/PIPELINE_DOCS.md b/docs/_standards/PIPELINE_DOCS.md index ace257b..a17f07a 100644 --- a/docs/_standards/PIPELINE_DOCS.md +++ b/docs/_standards/PIPELINE_DOCS.md @@ -2,7 +2,7 @@ > **Назначение.** Единая карта «стадия → агент → документ → категория → гейт/механизм → > frontmatter machine-key» + конвенция ADR-naming. Это **golden source структуры** номерных -> документов work item (`00-business-request.md` … `17-security-report.md`), который каждая +> документов work item (`00-business-request.md` … `18-coverage-report.md`), который каждая > агентская роль пишет на своей стадии. > > **Статус истины (важно).** Манифест **документирует** текущее поведение гейтов, но НЕ является @@ -60,6 +60,7 @@ check_tests_passed → check_staging_status → check_deploy_status`. | `15-staging-log.md` | deployer | required (self-hosting) | `deploy-staging` | `check_staging_status` (self-hosting; иначе N/A — ORCH-35) | `staging_status:` (`SUCCESS` \| `FAILED`) | | `16-post-deploy-log.md` | post-deploy-monitor | when-applicable | пост-`done` наблюдение (ORCH-021; не ребро `STAGE_TRANSITIONS`) | информационный (гейтом не парсится) | `post_deploy_status:` (`HEALTHY` \| `DEGRADED`) | | `17-security-report.md` | security-гейт (детерминированный, ORCH-022) | when-applicable | под-гейт ребра `deploy-staging→deploy` | `check_security_gate` (врезка в `advance_stage`) | `security_status:` (`PASS` \| `FAIL`) | +| `18-coverage-report.md` | coverage-гейт (детерминированный, ORCH-027) | when-applicable | под-гейт ребра `deploy-staging→deploy` (ПОСЛЕ merge-gate, ДО image-freshness) | `check_coverage_gate` (врезка в `advance_stage`) | `coverage_status:` (`PASS` \| `FAIL`) | ### Примечания манифеста (нормативные) @@ -86,6 +87,7 @@ check_tests_passed → check_staging_status → check_deploy_status`. | `14-deploy-log.md` | `deploy_status:` | `_parse_deploy_status` | `SUCCESS` → `done`; `FAILED` → откат (БАГ-8) | | `15-staging-log.md` | `staging_status:` | `_parse_staging_status` | `SUCCESS` → дальше; `FAILED` → откат (self-hosting; иначе N/A) | | `17-security-report.md` | `security_status:` | `check_security_gate` | `PASS` → дальше; `FAIL` → откат | +| `18-coverage-report.md` | `coverage_status:` | `check_coverage_gate` | `PASS` → дальше; `FAIL` → откат на `development` | **Информационные доки** — гейтом НЕ парсятся (структура ничего не блокирует): `00-business-request.md` (вход), `08-data-requirements.md`, `10-tech-risks.md`, diff --git a/docs/_templates/18-coverage-report.md b/docs/_templates/18-coverage-report.md new file mode 100644 index 0000000..9b52a0d --- /dev/null +++ b/docs/_templates/18-coverage-report.md @@ -0,0 +1,29 @@ +--- +coverage_status: PASS # PASS | FAIL (machine-key — читает check_coverage_gate) +work_item: ORCH-NNN +measured_coverage: 0.0 # измеренное line coverage src/ (%, float) +baseline: 0.0 # базовая линия main на момент измерения (%, или пусто при bootstrap) +floor: 0.0 # абсолютный порог coverage_min_percent (%) +policy: both # absolute | baseline | both +epsilon: 0.5 # допуск на шум измерения (%) +delta: 0.0 # measured − max(baseline, floor) (%, знаковая дельта) +--- + +# Coverage Report — ORCH-NNN + +> Детерминированный гейт покрытия (ORCH-027) — под-гейт ребра `deploy-staging→deploy` (врезка в +> `advance_stage`, ПОСЛЕ merge-gate, ДО image-freshness; не строка `STAGE_TRANSITIONS`). Машинный +> вердикт читается ТОЛЬКО из `coverage_status:`. `PASS` → дальше; `FAIL` → откат на `development`. +> Измерение — `pytest --cov=src --cov-report=json` в изолированном worktree. Source of truth +> измеренного значения для ratchet базовой линии (`_handle_merge_verify`, ребро `deploy→done`). + +## Verdict + + +## Measurement +<Инструмент (pytest-cov/coverage.py), команда, line coverage src/ = X%; либо fail-open WARNING +при ошибке инструмента (coverage_tool_fail_closed=False).> + +## Policy +<Режим (absolute|baseline|both), порог floor, базовая линия main, epsilon, какое условие +нарушено при FAIL.> diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 6a16160..ae4a622 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -39,7 +39,7 @@ created → analysis → architecture → development → review → testing → | deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) | | done | — | — | — | -**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022). +**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022), check_coverage_gate (ORCH-027). **Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`. **Единый frontmatter-контракт (ORCH-52c / ORCH-076):** парсинг YAML-frontmatter сведён к одной точке — `src/frontmatter.parse_frontmatter` (структура `data/has_block/malformed/yaml_error`, never-raise); пять вердикт-парсеров (`check_reviewer_verdict`, `_parse_tests_verdict`, `_parse_deploy_status`, `_parse_staging_status`, `parse_security_status`) делегируют ей вместо дублированной ad-hoc логики. Модуль также несёт writer (`render/write_frontmatter`), валидатор обязательной схемы (`validate_schema`/`REQUIRED_FIELDS`, warning-only по умолчанию; hard-fail только под kill-switch `frontmatter_validation_strict`, дефолт `False`) и общий `strip_frontmatter`. Семантика вердиктов / `STAGE_TRANSITIONS` / состав `QG_CHECKS` — без изменений (1:1). @@ -189,6 +189,47 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`. Безусловный pre-merge rebase + связь с зависимостями задач — [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md) (ORCH-026). +### Coverage-гейт: защита от деградации покрытия тестами (ORCH-027 — design) +Существующие тестовые гейты (`check_ci_green`, `check_tests_passed`, merge-gate re-test) судят +только по факту прохождения тестов, не по **полноте** — фича «300 строк, 0 тестов» проходит +незамеченной, и при пакетном автономном прогоне (ORCH-088) покрытие монотонно деградирует. +ORCH-027 вводит детерминированный (без LLM) **гейт покрытия как под-гейт ребра +`deploy-staging → deploy`** — рядом с security/merge/image-freshness, по тому же паттерну +(leaf `src/coverage_gate.py` never-raise + обёртка `check_coverage_gate` в `QG_CHECKS` + врезка +`_handle_coverage_gate` в `advance_stage`). `STAGE_TRANSITIONS` не меняется. +- **Порядок: security → merge → coverage → image-freshness.** Coverage идёт **ПОСЛЕ merge-gate** + (ветка догнана на свежий `origin/main` → меряем покрытие landed-кода) и **ДО image-freshness** + (фейлить дёшево до docker-rebuild). На этой точке merge-lease **held** → FAIL **освобождает + lease** при откате (как image-freshness rollback; в отличие от security — тот до захвата lease). +- **Измерение:** `pytest-cov` (`coverage.py`), `python -m pytest tests/ --cov=src + --cov-report=json` в изолированном worktree (`ensure_worktree`); метрика `totals.percent_covered` + (line coverage `src/`). Тайм-аут `coverage_run_timeout_s`. +- **Решение — чистая функция** `compute_coverage_verdict(measured, baseline, floor, policy, + epsilon)`: `absolute` (≥floor−ε) / `baseline` (≥baseline−ε, ratchet) / `both` (дефолт); + `baseline=None` → bootstrap. FAIL → откат на `development` + developer-retry (cap + `MAX_DEVELOPER_RETRIES`), дословный reason в `task_desc` (ORCH-046). +- **Базовая линия — аддитивная БД-таблица** `coverage_baseline(repo PK, coverage, source_sha, + updated_at)` (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/`job_deps`; выбор БД над + файлом-в-репо — нет git-churn/конфликтов на ratchet). **Ratchet-up** в choke-point + подтверждённого merge `_handle_merge_verify` (ребро `deploy→done`, ORCH-071/073): читает + измеренное покрытие из `18-coverage-report.md`, атомарный compare-and-set + `UPDATE ... WHERE coverage <= measured` (базовая линия не падает) под held merge-lease + + per-repo сериализацией merge (ORCH-043). +- **Условность (как ORCH-35/43/58):** `coverage_gate_enabled` + `coverage_gate_repos` (пусто → + только self-hosting `orchestrator`); вне области → no-op pass; `applies(repo)` ПЕРВОЙ. + **Ошибка инструмента → fail-open + WARNING** по умолчанию (`coverage_tool_fail_closed=False`, + анти-петля как ORCH-061); флаг → fail-closed. +- **Артефакт `18-coverage-report.md`** (frontmatter `coverage_status: PASS|FAIL` + + `measured_coverage`/`baseline`/`floor`/`policy`/`delta`), вердикт читается ТОЛЬКО из + frontmatter через `src/frontmatter.py`. Наблюдаемость — read-only блок `coverage` в + `GET /queue`; FAIL → Telegram (кликабельный номер, измеренное/порог/дельта); опциональный + `POST /coverage/baseline` (ручной override). never-raise; гейт не деплоит/не рестартит прод/ + не пушит в `main` (NFR-3). При выключенном флаге — нулевая регрессия (enduro не затронут). + +Подробнее: [adr-0029](adr/adr-0029-coverage-gate.md), детально — +`docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md`, +`docs/work-items/ORCH-027/08-data-requirements.md`. + ### Зависимости задач: B ждёт A (ORCH-026, Уровень B) Плоская очередь ORCH-1 (FIFO по `id` + `available_at` + `max_concurrency`) не выражала логических зависимостей. ORCH-026 вводит декларативные связи «задача B не стартует, пока не готовы её depends-on» — без новой стадии и без изменения `STAGE_TRANSITIONS`/`QG_CHECKS`. - **Источник истины планировщика — БД** (аддитивная таблица `job_deps(task_id, depends_on_task_id)`): claim в горячем цикле обслуживает очередь ВСЕХ проектов и обязан быть offline-устойчив (сетевой Plane на каждый claim = встанет очередь всех проектов). Источник **декларации** настраивается `task_deps_source = db|plane|hybrid` (дефолт `db`; `plane`/`hybrid` читают Plane relations в `handle_work_item_created` и кэшируют в `job_deps`). diff --git a/docs/architecture/adr/adr-0029-coverage-gate.md b/docs/architecture/adr/adr-0029-coverage-gate.md new file mode 100644 index 0000000..9f17e85 --- /dev/null +++ b/docs/architecture/adr/adr-0029-coverage-gate.md @@ -0,0 +1,92 @@ +--- +work_item: ORCH-027 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# adr-0029: Гейт покрытия тестами — edge sub-gate + ratchet-базовая линия + +- **Статус:** proposed +- **Дата:** 2026-06-10 +- **Задача:** ORCH-027 +- **Детальный ADR:** `docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md` + +## Контекст +Оркестратор автономен: `developer` пишет код без человека-фильтра, `tester` сам решает, хватает +ли тестов. Существующие тестовые гейты судят только по факту прохождения, не по полноте: +`check_ci_green` (exit-code CI), `check_tests_passed` (LLM-вердикт `tester`'а), merge-gate +re-test (exit-code). Ни один не замечает «300 строк кода, 0 тестов». При пакетном автономном +прогоне (ORCH-088) это монотонная деградация покрытия. Нужна детерминированная метрика — по духу +как security-гейт (adr-0012). + +## Решение +Детерминированный (без LLM) **гейт покрытия как под-гейт ребра `deploy-staging → deploy`**, +рядом с security-gate (ORCH-022), merge-gate (ORCH-043), image-freshness (ORCH-058). Паттерн — +leaf-модуль `src/coverage_gate.py` (never-raise) + обёртка в `QG_CHECKS` (`check_coverage_gate`) ++ врезка `_handle_coverage_gate` в `advance_stage`. `STAGE_TRANSITIONS` не меняется. + +- **Порядок: security → merge → `coverage` → image-freshness.** Coverage идёт **ПОСЛЕ + merge-gate** (ветка догнана на свежий `origin/main` → меряем покрытие того кода, что landed) и + **ДО image-freshness** (фейлить дёшево до docker-rebuild). На этой точке merge-lease **held** → + **FAIL обязан освободить lease** при откате (как image-freshness rollback; в отличие от + security, который идёт до захвата lease). +- **Измеритель:** `pytest-cov` (`coverage.py`), `python -m pytest tests/ --cov=src + --cov-report=json` в изолированном worktree (`ensure_worktree`); метрика — + `totals.percent_covered`. Тайм-аут `coverage_run_timeout_s`. Скоуп — `src/` (не тесты). +- **Чистая функция** `compute_coverage_verdict(measured, baseline, floor, policy, epsilon)`: + `absolute` (≥floor−ε), `baseline` (≥baseline−ε, ratchet), `both` (дефолт). `baseline=None` → + bootstrap (только absolute). FAIL → откат на `development` + developer-retry (cap + `MAX_DEVELOPER_RETRIES`), дословный reason в `task_desc` (ORCH-046). +- **Базовая линия — аддитивная БД-таблица** `coverage_baseline(repo PK, coverage, source_sha, + updated_at)` (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/`job_deps`). Выбор БД над + файлом-в-репо: нет git-churn/конфликтов на ratchet, restart-safe, атомарное обновление. +- **Ratchet-up** в choke-point подтверждённого merge `_handle_merge_verify` (ребро + `deploy → done`, ORCH-071/073): читает измеренное покрытие из `18-coverage-report.md`, + атомарный compare-and-set `UPDATE ... WHERE coverage <= measured` (базовая линия не падает). + Под held merge-lease + per-repo сериализацией merge (ORCH-043) — двойная анти-гонка. +- **Артефакт `18-coverage-report.md`** с frontmatter `coverage_status: PASS|FAIL` (+ + `measured_coverage`/`baseline`/`floor`/`policy`/`delta` + аддитивная 52c-схема); вердикт + читается ТОЛЬКО из frontmatter через `src/frontmatter.py` (single source of truth). +- **Условность (как ORCH-35/43/58):** `coverage_gate_enabled` + `coverage_gate_repos` (пусто → + только self-hosting `orchestrator`); вне области → no-op pass. `applies(repo)` ПЕРВОЙ, дорогой + прогон — только при applies. +- **Ошибка инструмента → fail-open + WARNING** по умолчанию (`coverage_tool_fail_closed=False`, + анти-петля как ORCH-061); флаг → fail-closed. +- **Наблюдаемость:** read-only блок `coverage` в `GET /queue`; FAIL → Telegram (кликабельный + номер, измеренное/порог/дельта). Опциональный `POST /coverage/baseline` (ручной override). +- **never-raise**, гейт не деплоит/не рестартит прод/не пушит в `main` (NFR-3). + +## Альтернативы +- **CI-job (`check_ci_green`):** пороги/политика/baseline/артефакт плохо выражаются статусом + коммита; ratchet требует записи в БД. Отклонено для v1 (точка расширения). +- **Edge `testing → deploy-staging`:** ветка не догнана на свежий `main` → метрика неточна; + откат не освобождает lease. Отклонено. +- **Базовая линия в файле репо:** git-churn/конфликты на каждый ratchet. Отклонено. +- **Новая стадия `coverage`:** «пустая» стадия без агента не имеет триггера (как ORCH-043/022). + Отклонено. +- **Жёсткий absolute-порог без baseline/epsilon:** массовые ложные заворота. Отклонено. + +## Последствия +- Класс «тихо просевшее покрытие» закрыт детерминированной метрикой; baseline только растёт. +- Нулевая регрессия вне области (enduro-trails); `STAGE_TRANSITIONS`/`QG_CHECKS`-семантика/ + вердикт-ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) — + байт-в-байт прежние; новая БД-таблица аддитивна. +- Плата: ещё один «скрытый» под-гейт ребра; новая pip-зависимость (`pytest-cov`); доп. прогон + pytest (после merge-gate re-test, ограничен таймаутом, фейлит до rebuild); v1 — Python-only. +- Дефолтный fail-open тихо пропускает при устойчивом сбое инструмента (с WARNING) — + переключаемо `coverage_tool_fail_closed`. +- Сквозное изменение (новый QG + edge-под-гейт + новая таблица + новый артефакт) → + `arch:major-change`; прод-деплой строго через staging-гейт (8501), без рестарта прод-контейнера. +- **Откат:** `coverage_gate_enabled=False` → полный no-op (мгновенный обратимый kill-switch). + +## Связи +adr-0012 (security-гейт — паттерн edge-под-гейта/leaf/never-raise/fail-open), adr-0006 +(merge-gate — edge-под-гейт/откат/merge-lease), adr-0008 (image-freshness — условность/ +fail-closed/release-lease-on-rollback), adr-0003 (условный гейт / `is_self_hosting_repo`), +adr-0009 (анти-петля ложных FAIL, ORCH-061), adr-0013/adr-0014 (merge-verify / SHA-in-main как +source of truth — точка ratchet), adr-0015/adr-0017 (per-repo сериализация merge/serial-gate), +adr-0020 (frontmatter-контракт — парсинг `coverage_status:`), adr-0019 (PIPELINE_DOCS — артефакт +`18-coverage-report.md`), ORCH-9/15 (мульти-стек — будущая зависимость BR-6). diff --git a/docs/work-items/ORCH-027/00-business-request.md b/docs/work-items/ORCH-027/00-business-request.md new file mode 100644 index 0000000..561b3a8 --- /dev/null +++ b/docs/work-items/ORCH-027/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Code coverage как гейт (защита от деградации покрытия тестами) + +Work Item ID: ORCH-027 + +## Description + +TBD diff --git a/docs/work-items/ORCH-027/01-brd.md b/docs/work-items/ORCH-027/01-brd.md new file mode 100644 index 0000000..2cdc0b8 --- /dev/null +++ b/docs/work-items/ORCH-027/01-brd.md @@ -0,0 +1,166 @@ +--- +work_item: ORCH-027 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-027 — Code coverage как гейт (защита от деградации покрытия тестами) + +Work Item: **ORCH-027** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Оркестратор ведёт **автономную** разработку: код пишет агент `developer` без человеческого +фильтра, а на стадии `testing` агент `tester` сам решает, достаточно ли тестов. Существующие +тестовые гейты проверяют только **факт прохождения** тестов, а не их **полноту**: + +- `check_ci_green` (ребро `development → review`) — зелёный прогон `pytest tests/` в Gitea CI + (`.gitea/workflows/ci.yml`), судит по exit-code, покрытие **не меряет**. +- `check_tests_passed` (ребро `testing → deploy-staging`) — читает machine-verdict + `result:`/`verdict:`/`status:` из `13-test-report.md`; это вердикт LLM-`tester`'а, а не + измеренная метрика. +- Merge-gate re-test (ORCH-043) — повторный `pytest` на догнанной ветке, тоже только exit-code. + +Ни один гейт не замечает, что фича добавила 300 строк кода и 0 тестов, или что багфикс +изменил поведение без регрессионного теста. При пакетном автономном прогоне (эпик ORCH-088, +«10–20 задач за ночь») это означает **монотонную деградацию покрытия**: каждая задача может +«срезать угол» на тестах, и за десятки задач проект тихо теряет тестируемость. Предложено +Стрим, одобрено Славой (`00-business-request.md`). + +**Задача вводит измеримый гейт покрытия**: покрытие тестами измеряется инструментально и не +должно опускаться ниже политики (абсолютный порог и/или «не ниже базовой линии»). Это +структурная защита от деградации, аналогичная по духу security-гейту (ORCH-022) — +детерминированная метрика вместо доверия суждению агента. + +> **Self-hosting.** Гейт работает на инструменте, который в проде обслуживает все проекты из +> общей БД и очереди (`CLAUDE.md` §self-hosting). Измерение покрытия — это исполнение тест-сьюта +> в изолированном worktree; оно **не трогает прод-контейнер и не касается `main`**. + +## 2. Объём (scope) + +### В объёме +- Инструментальное измерение покрытия тестами для репозитория `orchestrator` (стек Python / + pytest) перед слиянием ветки задачи в `main`. +- Гейт-решение: покрытие **не ниже** заданной политики порога. Политика поддерживает два режима: + абсолютный порог (`%`) и «не ниже базовой линии» (no-regression / ratchet), а также их + комбинацию. +- Хранение и обновление **базовой линии** покрытия (last-known покрытие `main`). +- Наблюдаемость результата: артефакт-отчёт о покрытии с machine-readable вердиктом, строка в + `GET /queue`, сигнал в Telegram при провале. +- Конфигурируемость: kill-switch + per-repo область + настраиваемый порог/политика + + поведение при ошибке инструмента (fail-open/closed). + +### Вне объёма +- Реализация измерения покрытия для НЕ-Python стеков (jest / jacoco для будущих репозиториев) — + фактическая интеграция инструментов оставлена на будущее; в ORCH-027 закладывается лишь + расширяемость (политика и хранилище не должны быть жёстко завязаны на Python). +- Изменение существующей семантики `check_ci_green` / `check_tests_passed` / + `check_reviewer_verdict` для репозиториев, где гейт покрытия выключен. +- Принудительное доведение покрытия до 100% или установка агрессивного абсолютного порога — + стартовая политика консервативна (см. NFR-4). +- Покрытие самих тестовых файлов и мутационное тестирование. +- Выбор конкретного инструмента/механизма интеграции и его расположения в конвейере как + архитектурного решения — это зона архитектора (`06-adr/`); BRD/ТЗ фиксируют требования и + кандидатные точки, выведенные из фактического кода. + +## 3. Заинтересованные стороны + +- **Заказчик / инициатор:** Стрим (предложение), Слава (одобрение). +- **Затрагиваются:** конвейер `orchestrator` (self-hosting); агенты `developer`/`tester` + (теперь обязаны держать покрытие); проект enduro-trails — **не должен быть затронут** (гейт + по умолчанию неактивен вне сконфигурированных репозиториев). +- **Принимает результат:** reviewer (стадия `review`) + финальная стадия конвейера; владелец + (Owner) — по факту работы гейта в проде. + +## 4. Бизнес-требования (BR) + +- **BR-1 — Измерение покрытия.** Перед слиянием ветки задачи в `main` покрытие тестами + репозитория измеряется инструментально (исполнением тест-сьюта под coverage-инструментацией), + а не оценивается на глаз. Результат — числовая метрика покрытия (как минимум line coverage). +- **BR-2 — Гейт деградации.** Если измеренное покрытие нарушает политику (ниже абсолютного + порога ИЛИ ниже базовой линии — в зависимости от выбранного режима), конвейер **не + пропускает** задачу дальше к деплою и инициирует штатный откат на `development` для доработки + тестов. +- **BR-3 — Базовая линия (ratchet).** Поддерживается режим «не ниже предыдущего»: гейт + сравнивает покрытие ветки с зафиксированной базовой линией `main`. Базовая линия **обновляется + вверх** при успешном слиянии задачи в `main` (покрытие может только расти или держаться, но + не падать). +- **BR-4 — Конфигурируемость и нулевая регрессия.** Гейт управляется kill-switch'ем и + per-repo областью (по образцу `merge_gate`/`security_gate`/`image_freshness`, + ORCH-035/043/058). Для репозиториев вне области (в частности enduro-trails) гейт — **полный + no-op**, поведение конвейера 1:1 как до задачи. Порог, политика (absolute|baseline|both) и + поведение при ошибке инструмента — настраиваемы. +- **BR-5 — Наблюдаемость.** Результат измерения виден: (а) артефакт-отчёт о покрытии с + machine-readable вердиктом в `docs/work-items//`; (б) read-only блок в `GET /queue`; + (в) уведомление в Telegram при провале гейта (кликабельный номер задачи, как у прочих + алертов). Сообщение указывает измеренное покрытие, порог/базовую линию и дельту. +- **BR-6 — Стек-расширяемость.** Логика политики (PASS/FAIL по метрике/базовой линии) и + хранилище базовой линии не зависят от конкретного инструмента; добавление измерителя для + другого стека (jest/jacoco) в будущем не требует переписывания ядра гейта. + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 — never-raise / fail-safe.** Ядро гейта — изолированный leaf-модуль (по образцу + `src/security_gate.py`, `src/serial_gate.py`, `src/labels.py`): любая внутренняя ошибка + обрабатывается, исключение **никогда** не всплывает в `advance_stage` и не роняет конвейер + всех проектов. +- **NFR-2 — Поведение при недоступности/ошибке инструмента.** По умолчанию ошибка измерения + (coverage-инструмент упал/недоступен) → **fail-open + громкий warning** (анти-петля, + прецедент ORCH-061/ORCH-022 dep-audit), переключаемое в fail-closed флагом. Дефолт не должен + заклинивать автономный конвейер из-за инфраструктурного сбоя. +- **NFR-3 — Self-hosting безопасность.** Гейт только исполняет тесты в изолированном worktree, + читает метрику, пишет отчёт и принимает решение. Он **никогда** не вызывает деплой-хук, не + перезапускает прод-контейнер, не пушит/форс-пушит в `main`. +- **NFR-4 — Консервативный старт (анти-флап).** Стартовая политика не должна массово заворачивать + существующие задачи: базовая линия инициализируется фактическим покрытием `main`, абсолютный + порог — как мягкий backstop. Допускается малый отрицательный допуск (epsilon) на шум измерения, + чтобы дрожание ±доли процента не заворачивало задачу. +- **NFR-5 — Совместимость.** `STAGE_TRANSITIONS`, состав/семантика `QG_CHECKS` и `check_*`, + machine-verdict ключи существующих доков (`verdict:`/`result:`/`deploy_status:`/ + `staging_status:`/`security_status:`) — не меняются. Любая новая БД-сущность — аддитивна + (без миграции существующих таблиц). Restart-safe. +- **NFR-6 — Детерминизм.** Решение гейта — чистая функция от (измеренное покрытие, базовая + линия, порог, политика); без участия LLM в критическом пути (как security/merge/image-freshness + под-гейты). + +## 6. Допущения и ограничения + +- Тест-сьют `orchestrator` запускается командой `python -m pytest tests/` из корня репозитория + (подтверждено `.gitea/workflows/ci.yml`, `pytest.ini` `testpaths = tests`); измерение покрытия + накладывается на этот же прогон. +- Coverage-инструмент для Python (`coverage.py` / `pytest-cov`) добавляется как pip-зависимость; + он не требует сети во время измерения. +- Репозиторий `orchestrator` — единственный self-hosting (предикат `is_self_hosting_repo`); + стартовая область гейта — он. enduro-trails и прочие репозитории по умолчанию вне области. +- Базовая линия привязана к покрытию `main`; её первичная инициализация выполняется один раз + (bootstrap) фактическим замером текущего `main`. +- Тесты исполняются в per-branch worktree (`ensure_worktree`), что безопасно при параллельных + активных задачах (прецедент `check_tests_local`/merge-gate re-test). + +## 7. Критерии успеха + +- Покрытие тестами `orchestrator` измеряется на каждой задаче и не может опуститься ниже + политики, не заблокировав продвижение к деплою. +- При выключенном флаге / вне области — конвейер ведёт себя 1:1 как до ORCH-027 (нулевая + регрессия для enduro-trails). +- Сбой coverage-инструмента не заклинивает автономный конвейер (дефолт fail-open + warning). +- Результат измерения прозрачен (отчёт + `GET /queue` + Telegram при провале). + +Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски + +- **Флап на шуме измерения** — недетерминированное покрытие (например, зависящее от порядка/ + окружения) может дрожать у границы → ложные заворота. Митигировать epsilon-допуском (NFR-4). +- **Петля заворотов** — слишком высокий абсолютный порог завернёт многие задачи в бесконечный + rework. Митигировать консервативной стартовой политикой и baseline-режимом. +- **Гонка базовой линии** при параллельных слияниях — два слияния в `main` могут конкурентно + обновлять baseline. Требуется атомарное/сериализованное обновление (опереться на окно + сериализации merge-lease, ORCH-043). +- **Инфраструктурная хрупкость** — coverage-инструмент недоступен/несовместим с версией pytest → + закрыто требованием NFR-2 (fail-open + warning). + +Детальная техническая проработка рисков — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-027/02-trz.md b/docs/work-items/ORCH-027/02-trz.md new file mode 100644 index 0000000..0549c19 --- /dev/null +++ b/docs/work-items/ORCH-027/02-trz.md @@ -0,0 +1,156 @@ +--- +work_item: ORCH-027 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-027 — Code coverage как гейт + +Work Item: **ORCH-027** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные требования к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование и выбор механизма (где именно врезать гейт, как хранить базовую +> линию, какой инструмент) — задача архитектора (`06-adr/`). Ниже зафиксированы требования и +> **кандидатные** точки интеграции, грунтованные реальным кодом; финальное решение по каждой +> отмеченной точке принимает архитектор. + +## 1. Сводка изменения + +Вводится **детерминированный гейт покрытия тестами** для репозитория `orchestrator`. Гейт +измеряет покрытие исполнением тест-сьюта под coverage-инструментацией, сравнивает с политикой +(абсолютный порог и/или базовая линия `main`) и блокирует продвижение задачи к деплою при +деградации, инициируя штатный откат на `development`. Ядро — изолированный leaf-модуль с чистой +логикой решения (по образцу `security_gate`/`serial_gate`), управляемый kill-switch'ем и per-repo +областью; вне области — полный no-op. Базовая линия покрытия `main` хранится персистентно и +обновляется вверх при слиянии (ratchet). + +## 2. Задействованные модули / пути + +| Путь | Действие | Назначение | +|------|----------|-----------| +| `requirements.txt` | изменить | добавить coverage-зависимость Python (`coverage.py` / `pytest-cov`; точный выбор — архитектор) | +| `src/coverage_gate.py` | создать | **NEW leaf-модуль**: измерение покрытия (run suite под coverage в `ensure_worktree`), чистые функции `compute_coverage_verdict(measured, baseline, floor, policy, epsilon)` и классификация, чтение/запись отчёта; never-raise; импортирует только `config`/`git_worktree` (+ лениво `qg.checks.is_self_hosting_repo`/`notifications`) | +| `src/config.py` | изменить | добавить флаги гейта (см. §6 ниже / раздел совместимости) | +| `src/qg/checks.py` | изменить | зарегистрировать механизм проверки покрытия (новый `check_*` ЛИБО делегирование из под-гейта); **семантика существующих `check_*` не меняется** | +| `src/stage_engine.py` | изменить *(кандидат)* | врезка под-гейта в `advance_stage` по образцу `_handle_security_gate`/`_handle_merge_gate` — если выбран механизм «edge sub-gate» (см. §3 FR-3) | +| `src/db.py` | изменить *(кандидат)* | аддитивная таблица базовой линии покрытия (`coverage_baseline` per-repo), если базовая линия хранится в БД, а не в файле; `_ensure_column`/`CREATE TABLE IF NOT EXISTS` — без миграции существующих | +| `.gitea/workflows/ci.yml` | изменить *(кандидат)* | если измерение делается в CI-шаге — добавить `--cov`/порог в прогон pytest; **точка измерения — решение архитектора** | +| `src/main.py` | изменить | read-only блок `coverage` в `GET /queue` (наблюдаемость) | +| `docs/work-items//-coverage-report.md` | создать (артефакт run-time) | отчёт о покрытии с machine-readable вердиктом (см. §4/§6); номер/имя и регистрация в `docs/_standards/PIPELINE_DOCS.md` + скелет в `docs/_templates/` — оформляет архитектор | +| `tests/test_coverage_gate.py` | создать | unit/integration по `04-test-plan.yaml` | + +## 3. Функциональные требования + +### FR-1 — Измерение покрытия (привязка BR-1) +Гейт исполняет тест-сьют `orchestrator` (`python -m pytest tests/`, см. `.gitea/workflows/ci.yml`) +под coverage-инструментацией в изолированном per-branch worktree (`ensure_worktree`, прецедент +`check_tests_local`) и извлекает числовую метрику покрытия (как минимум суммарный line coverage, +`%`). Тайм-аут на прогон ограничен (по образцу `merge_retest_timeout_s` / `security_scan_timeout_s`). + +### FR-2 — Решение гейта (привязка BR-2, BR-3) +Чистая функция `compute_coverage_verdict(measured, baseline, floor, policy, epsilon) -> (ok, reason)`: +- `policy = absolute` → PASS ⇔ `measured >= floor - epsilon`. +- `policy = baseline` → PASS ⇔ `measured >= baseline - epsilon`. +- `policy = both` (дефолт) → PASS ⇔ выполнены оба условия. +- FAIL → гейт инициирует штатный откат на `development` для доработки тестов (по образцу + `_handle_security_gate` / merge-gate rollback), с инкрементом счётчика developer-retry. +- `epsilon` — малый неотрицательный допуск на шум измерения (NFR-4), настраиваемый. + +### FR-3 — Точка в конвейере (привязка BR-2; **кандидат, решает архитектор**) +Бизнес-запрос указывает «на testing-гейте». Грунтованные кодом кандидаты (выбрать один): +- **(a) Edge sub-gate** в `advance_stage` на ребре `deploy-staging → deploy` (рядом с + `_handle_security_gate`/`_handle_merge_gate`/`_handle_image_freshness`) — даёт гарантию «гейт + ДО слияния в `main`», детерминирован, владеет исходом на вмешательстве. Предпочтительно для + соответствия NFR-3/NFR-6. +- **(b) Под-гейт/расширение на ребре `testing → deploy-staging`** (рядом с `check_tests_passed`). +- **(c) CI-шаг** в `.gitea/workflows/ci.yml` (ребро `development → review`, читается + `check_ci_green`) — порог проверяется самим pytest-прогоном. +Требование, инвариантное к выбору: гейт обязан отработать **до фактического merge в `main`** и не +пропускать деградацию в `main`. + +### FR-4 — Базовая линия и её обновление (привязка BR-3) +- Персистентное per-repo хранилище базовой линии покрытия `main` (БД-таблица ИЛИ файл в репо — + решает архитектор; при БД — аддитивная таблица, NFR-5). +- Bootstrap: первичная инициализация фактическим замером текущего `main`. +- Ratchet-up: при успешном слиянии задачи в `main` базовая линия обновляется значением + смёрженного покрытия, **только если оно ≥ текущей** (покрытие не откатывается вниз). Обновление + должно быть атомарным/сериализованным относительно параллельных слияний (опереться на окно + merge-lease, ORCH-043). + +### FR-5 — Условность и kill-switch (привязка BR-4) +- `coverage_gate_enabled=False` → гейт инертен, конвейер 1:1 как до ORCH-027. +- `coverage_gate_repos` (CSV) — область применения; **пусто → только self-hosting** + (`is_self_hosting_repo`, по образцу `merge_gate`/`security_gate`/`image_freshness`). +- Вне области → no-op `(True, "Coverage gate N/A")` (прецедент `check_staging_status` для + не-self-hosting, ORCH-035). +- `applies(repo)` (локальная проверка) выполняется ПЕРВОЙ; дорогой прогон измерения — только при + `applies==True`. + +### FR-6 — Поведение при ошибке инструмента (привязка NFR-2) +Ошибка/недоступность coverage-инструмента или невозможность распарсить метрику → по умолчанию +**fail-open + WARNING** (`coverage_tool_fail_closed=False`, прецедент `security_dep_audit_fail_closed`); +флаг переключает в fail-closed. Поведение логируется явной observability-строкой. + +### FR-7 — Наблюдаемость (привязка BR-5) +- Артефакт-отчёт `-coverage-report.md` с machine-readable вердиктом (см. §4). +- Read-only блок `coverage` в `GET /queue` (per-repo: `enabled`/`policy`/`floor`/`baseline`/ + последнее измеренное/вердикт). +- При FAIL — `send_telegram` (notifying) с кликабельным номером задачи (`plane_issue_link`), + измеренным покрытием, порогом/базовой линией и дельтой. + +## 4. Изменения API + +- **`GET /queue`** — добавить read-only блок `coverage` (наблюдаемость; форма прочих блоков + `serial_gate`/`security`/`merge`). Без изменения существующих полей ответа. +- **Опционально (решает архитектор):** ручной эндпоинт сброса/override базовой линии + (`POST /coverage/baseline?repo=…`) — по образцу `POST /serial-gate/unfreeze`, на случай + легитимного разового снижения покрытия. Если не вводится — override выполняется через конфиг. +- Существующие webhook-роуты (`/webhook/plane`, `/webhook/gitea`) — без изменений. + +## 5. Изменения схемы БД + +Зависит от выбора хранилища базовой линии (FR-4): +- **Если БД:** аддитивная таблица `coverage_baseline(repo TEXT PRIMARY KEY, coverage REAL, + updated_at, source_sha TEXT)` через `CREATE TABLE IF NOT EXISTS` (паттерн `repo_freeze`/ + `job_deps`). Существующие таблицы — **не мигрируются** (NFR-5). +- **Если файл в репо:** изменений схемы БД нет (базовая линия — версионируемый файл вроде + `.coverage-baseline.json`, читаемый/обновляемый под merge-lease). + +Выбор — архитектор; ТЗ требует лишь: персистентность, restart-safe, аддитивность, атомарность +обновления. + +## 6. Требования к новым/изменённым QG checks + +- **Новый машинный вердикт покрытия.** Если гейт реализован как edge sub-gate (FR-3a/b), он + **сам вычисляет** вердикт (как `check_security_gate`) и пишет отчёт `-coverage-report.md` + с frontmatter-ключом `coverage_status:` (`PASS` | `FAIL`), читаемым обратно из того же файла + (single source of truth, по образцу `security_status:` в `17-security-report.md`). Имя ключа + фиксируется и регистр чувствителен. +- **Реестр `QG_CHECKS`.** Допустимо добавить `check_coverage_gate` в реестр (если механизм — + зарегистрированный QG) ЛИБО оставить его врезкой-под-гейтом (как security/merge/image-freshness, + которые в `QG_CHECKS` присутствуют, но исполняются как врезки). **Семантика и состав + существующих `check_*` — без изменений** (NFR-5). +- **Парсинг frontmatter** вердикта — через единый контракт `src/frontmatter.py` + (`parse_frontmatter`/`read_frontmatter_value`), как все вердикт-парсеры (ORCH-052c). Если + отчёт несёт обязательную 6-польную схему 52c — добавить её аддитивно, не трогая `coverage_status:`. + +## 7. Совместимость / регресс + +- **Обратная совместимость:** при `coverage_gate_enabled=False` или для репозитория вне + `coverage_gate_repos` — поведение конвейера байт-в-байт прежнее; enduro-trails не затронут. +- **Kill-switch + поэтапный раскат:** `coverage_gate_enabled` (глобальный), `coverage_gate_repos` + (область). Старт — только `orchestrator`. +- **Конфиг-флаги (итог §3/§6):** `coverage_gate_enabled` (bool), `coverage_gate_repos` (CSV), + `coverage_min_percent` (float, абсолютный порог), `coverage_policy` (`absolute|baseline|both`, + дефолт `both`), `coverage_epsilon` (float, допуск шума), `coverage_tool_fail_closed` (bool, + дефолт `False`), `coverage_run_timeout_s` (int). Имена env — `ORCH_COVERAGE_*`. +- **never-raise / fail-open в hot-path:** ядро не роняет `advance_stage`; ошибка инструмента → + fail-open + warning по умолчанию (NFR-2). Прод-контейнер/`main`/force-push — не трогаются (NFR-3). +- **Restart-safe:** базовая линия персистентна; in-flight измерение при рестарте переигрывается + штатным механизмом стадии (idempotent). +- **Документация (golden source):** при выборе механизма архитектор регистрирует артефакт + `-coverage-report.md` и его machine-key в `docs/_standards/PIPELINE_DOCS.md` + + `docs/_templates/`, и обновляет `docs/architecture/README.md` и `CHANGELOG.md` в том же PR. diff --git a/docs/work-items/ORCH-027/03-acceptance-criteria.md b/docs/work-items/ORCH-027/03-acceptance-criteria.md new file mode 100644 index 0000000..97c3d0b --- /dev/null +++ b/docs/work-items/ORCH-027/03-acceptance-criteria.md @@ -0,0 +1,138 @@ +--- +work_item: ORCH-027 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-027 — Code coverage как гейт + +Work Item: **ORCH-027** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** +(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам +репозитория. + +--- + +## AC-1 — Покрытие измеряется инструментально + +**Условие:** на применимом репозитории конвейер измеряет покрытие тестами исполнением сьюта под +coverage-инструментацией перед слиянием в `main`. +- **PASS:** в коде есть путь, который запускает `pytest` под coverage в изолированном worktree и + извлекает числовую метрику line coverage (`%`); coverage-зависимость добавлена в `requirements.txt`. +- **FAIL:** покрытие не измеряется инструментально, метрика берётся из прозы/вердикта LLM, либо + зависимость не объявлена. + +--- + +## AC-2 — Гейт блокирует деградацию + +**Условие:** покрытие ниже политики не пропускается дальше к деплою. +- **PASS:** при измеренном покрытии ниже порога/базовой линии (с учётом epsilon) гейт даёт FAIL и + инициирует штатный откат на `development` (инкремент developer-retry), задача не достигает `done`. +- **FAIL:** задача с упавшим покрытием проходит гейт и продвигается к деплою/`done`. + +--- + +## AC-3 — Чистая функция решения + +**Условие:** вердикт — детерминированная чистая функция от (measured, baseline, floor, policy, epsilon). +- **PASS:** `compute_coverage_verdict(...)` покрыта unit-тестами для всех режимов + (`absolute`/`baseline`/`both`), границ (равно порогу), epsilon-допуска; без участия LLM. +- **FAIL:** решение принимает LLM, либо логика недетерминирована/не покрыта тестами границ. + +--- + +## AC-4 — Режим базовой линии (ratchet) + +**Условие:** поддержан режим «не ниже предыдущего» с обновлением базовой линии вверх при слиянии. +- **PASS:** базовая линия персистентна per-repo; при слиянии обновляется значением смёрженного + покрытия только если оно ≥ текущей; bootstrap инициализирует её фактическим покрытием `main`; + обновление атомарно/сериализовано относительно параллельных слияний. +- **FAIL:** базовая линия не хранится / откатывается вниз / обновляется неатомарно (гонка двух + слияний теряет/занижает значение). + +--- + +## AC-5 — Условность и нулевая регрессия + +**Условие:** вне области / при выключенном флаге — поведение конвейера 1:1 как до ORCH-027. +- **PASS:** при `coverage_gate_enabled=False` или repo ∉ `coverage_gate_repos` гейт — no-op + (`(True, "...N/A")`); существующая тестовая база (`pytest tests/`) зелёная; enduro-trails не + затронут; `applies(repo)` проверяется до дорогого прогона. +- **FAIL:** гейт срабатывает вне области, либо выключенный флаг меняет поведение, либо есть + регресс существующих тестов. + +--- + +## AC-6 — Fail-open по умолчанию при ошибке инструмента + +**Условие:** сбой/недоступность coverage-инструмента не заклинивает автономный конвейер. +- **PASS:** при ошибке измерения и `coverage_tool_fail_closed=False` гейт даёт PASS + WARNING-лог + (observability-строка); флаг `=True` переключает в fail-closed (FAIL). Поведение покрыто тестом. +- **FAIL:** ошибка инструмента по умолчанию заворачивает задачу (петля rework) либо роняет + `advance_stage`. + +--- + +## AC-7 — never-raise / self-hosting безопасность + +**Условие:** ядро гейта не роняет конвейер и не трогает прод/`main`. +- **PASS:** `src/coverage_gate.py` — leaf (не импортирует `stage_engine`); любое исключение + перехвачено и не всплывает в `advance_stage`; код не вызывает деплой-хук, не перезапускает + прод-контейнер, не пушит/форс-пушит в `main`/`master`. +- **FAIL:** исключение из гейта всплывает в `advance_stage`; гейт трогает прод-контейнер или `main`. + +--- + +## AC-8 — Совместимость контрактов + +**Условие:** существующие машинные контракты не изменены. +- **PASS:** `STAGE_TRANSITIONS`, семантика существующих `check_*`, machine-verdict ключи + (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) — байт-в-байт + прежние; любая новая БД-сущность аддитивна (без миграции существующих таблиц). +- **FAIL:** изменена семантика/имя существующего гейта или вердикт-ключа; миграция ломает + существующую схему. + +--- + +## AC-9 — Машинный вердикт покрытия и наблюдаемость + +**Условие:** результат измерения прозрачен и машинно читаем. +- **PASS:** при FAIL — Telegram-алерт с кликабельным номером задачи, измеренным покрытием, + порогом/базовой линией и дельтой; `GET /queue` несёт read-only блок `coverage`; артефакт-отчёт + с machine-readable вердиктом (`coverage_status: PASS|FAIL`) записан и читается обратно из того + же файла через `src/frontmatter.py`. +- **FAIL:** результат не виден в `GET /queue`/Telegram, либо вердикт парсится из прозы, а не из + frontmatter, либо имя ключа не зафиксировано (регистр). + +--- + +## AC-10 — Документация обновлена (golden source) + +**Условие:** документация синхронизирована с изменением в том же PR. +- **PASS:** если введён артефакт-отчёт — он зарегистрирован в `docs/_standards/PIPELINE_DOCS.md` + и `docs/_templates/`; обновлены `docs/architecture/README.md` (описание гейта/флагов) и + `CHANGELOG.md`; новые/изменённые инварианты несут маркер `ORCH-027`. +- **FAIL:** функционал введён без обновления обзорной/стандартной документации (reviewer → + REQUEST_CHANGES, ORCH-079). + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1 | +| AC-2 | BR-2 / FR-2, FR-3 | +| AC-3 | BR-2 / FR-2 / NFR-6 | +| AC-4 | BR-3 / FR-4 | +| AC-5 | BR-4 / FR-5 / NFR-5 | +| AC-6 | NFR-2 / FR-6 | +| AC-7 | NFR-1 / NFR-3 | +| AC-8 | NFR-5 / FR-6 (§6 ТЗ) | +| AC-9 | BR-5 / FR-7 / §6 ТЗ | +| AC-10 | Правила агентов §2/§6 (CLAUDE.md) | diff --git a/docs/work-items/ORCH-027/04-test-plan.yaml b/docs/work-items/ORCH-027/04-test-plan.yaml new file mode 100644 index 0000000..bbe6fe3 --- /dev/null +++ b/docs/work-items/ORCH-027/04-test-plan.yaml @@ -0,0 +1,110 @@ +work_item: ORCH-027 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +title: "Code coverage gate — защита от деградации покрытия тестами" +framework: pytest +scope: > + Покрываются: чистая логика вердикта покрытия (режимы absolute/baseline/both, границы, + epsilon), ratchet-обновление базовой линии, условность (kill-switch + per-repo область), + fail-open/fail-closed при ошибке инструмента, never-raise, наблюдаемость (GET /queue, + Telegram при FAIL), интеграция гейта в advance_stage / точку конвейера. Вне покрытия: + фактические измерители не-Python стеков (jest/jacoco), мутационное тестирование. +notes: > + Тесты не должны исполнять реальный прод-деплой и не трогают prod-контейнер/main. + Измерение покрытия в тестах мокается/стабится (фиктивная метрика), реальный pytest-прогон + под coverage проверяется отдельным интеграционным тестом на минимальном фикстур-репо/worktree. + Полный регресс tests/ должен оставаться зелёным (нулевая регрессия для enduro-trails). + +tests: + - id: TC-01 + type: unit + description: "compute_coverage_verdict, policy=absolute: measured>=floor → PASS; measured=baseline → PASS; ниже baseline-epsilon → FAIL (no-regression / ratchet)" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-03 + type: unit + description: "compute_coverage_verdict, policy=both: PASS только при выполнении обоих условий; нарушение любого → FAIL" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-04 + type: unit + description: "epsilon-допуск: дрожание покрытия в пределах epsilon у границы не заворачивает задачу (анти-флап, NFR-4)" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-05 + type: unit + description: "Ratchet базовой линии: при слиянии baseline растёт до смёрженного покрытия только если >= текущей; меньшее значение не понижает baseline" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-06 + type: unit + description: "Bootstrap базовой линии: первичная инициализация фактическим покрытием main при отсутствии сохранённого значения" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-07 + type: unit + description: "Условность applies(repo): пустой coverage_gate_repos → только self-hosting (is_self_hosting_repo); repo вне области → no-op (True, 'N/A'), дорогой прогон не запускается" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-08 + type: unit + description: "Kill-switch coverage_gate_enabled=False → гейт инертен, advance_stage ведёт себя 1:1 как до ORCH-027" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-09 + type: unit + description: "Fail-open по умолчанию: ошибка/недоступность coverage-инструмента и coverage_tool_fail_closed=False → PASS + WARNING-лог; флаг True → FAIL (fail-closed)" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-10 + type: unit + description: "never-raise: внутреннее исключение (битый вывод coverage, отсутствие worktree) перехватывается, не всплывает в advance_stage" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-11 + type: unit + description: "Запись/чтение отчёта: write_coverage_report пишет coverage_status: PASS|FAIL во frontmatter; parse читает обратно из того же файла через src/frontmatter.py (single source of truth)" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-12 + type: unit + description: "Self-hosting безопасность: гейт не вызывает деплой-хук, не перезапускает прод-контейнер, не пушит/форс-пушит в main/master" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-13 + type: integration + description: "Гейт в конвейере: при measured ниже политики advance_stage не продвигает к деплою и инициирует откат на development (инкремент developer-retry); при PASS — продвигает штатно" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-14 + type: integration + description: "Реальное измерение: pytest под coverage в ensure_worktree на минимальном фикстур-репо возвращает корректную метрику line coverage и тайм-аутится по coverage_run_timeout_s" + module: tests/test_coverage_gate.py + expected: PASS + + - id: TC-15 + type: integration + description: "Наблюдаемость: FAIL даёт Telegram-алерт с кликабельным номером (измеренное/порог/дельта); GET /queue несёт read-only блок coverage; совместимость — STAGE_TRANSITIONS/QG_CHECKS/существующие вердикт-ключи не изменены" + module: tests/test_coverage_gate.py + expected: PASS diff --git a/docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md b/docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md new file mode 100644 index 0000000..1477c9e --- /dev/null +++ b/docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md @@ -0,0 +1,266 @@ +--- +work_item: ORCH-027 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: 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, «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:/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` хранится в **БД**, не в файле репозитория: + +```sql +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`: + +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=&value=` (по +образцу `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) diff --git a/docs/work-items/ORCH-027/07-infra-requirements.md b/docs/work-items/ORCH-027/07-infra-requirements.md new file mode 100644 index 0000000..4b719f0 --- /dev/null +++ b/docs/work-items/ORCH-027/07-infra-requirements.md @@ -0,0 +1,64 @@ +--- +work_item: ORCH-027 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 07 — Инфраструктурные требования: ORCH-027 — Code coverage как гейт + +Work Item: **ORCH-027** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable. Топология **не меняется** (всё в существующем Docker-контейнере на одном +> сервере mva154, SQLite, собственная очередь). Затрагивается только зависимостный и +> конфигурационный слой. + +## Топология / окружение + +- **Без изменений топологии** — никаких новых контейнеров/сервисов/нод. Гейт исполняется внутри + существующего процесса оркестратора, измерение — в per-branch worktree (`ensure_worktree`), + как merge-gate re-test. `docs/operations/INFRA.md` — без правок. +- **Self-hosting безопасность (NFR-3):** гейт не вызывает деплой-хук, не рестартит прод-контейнер + `orchestrator` (8500), не пушит в `main`. Прод-деплой ORCH-027 — **только** через + staging-гейт (8501) → выделенный статус «Confirm Deploy» (ORCH-059), без рестарта прод + случайным approve. + +## Зависимости + +| Зависимость | Где | Назначение | +|-------------|-----|-----------| +| `pytest-cov` (обёртка `coverage.py`) | `requirements.txt` | измерение line coverage прогоном `pytest --cov=src --cov-report=json`. Offline (сеть при измерении не нужна). Попадает в прод-образ при пересборке. | + +- Версия фиксируется совместимой с текущим `pytest` (см. `requirements.txt`/`pytest.ini`). +- Новых системных пакетов в `Dockerfile` не требуется (чистый pip-пакет). + +## Конфигурация (env, `.env` на хосте) + +Новые флаги (`config.py`, префикс `ORCH_COVERAGE_*`; дефолты безопасны — нулевая регрессия): + +| Env | Дефолт | Назначение | +|-----|--------|-----------| +| `ORCH_COVERAGE_GATE_ENABLED` | `false` (раскат поэтапный) | kill-switch | +| `ORCH_COVERAGE_GATE_REPOS` | пусто → только self-hosting | CSV область применения | +| `ORCH_COVERAGE_MIN_PERCENT` | консервативно (напр. backstop) | абсолютный порог-floor | +| `ORCH_COVERAGE_POLICY` | `both` | `absolute\|baseline\|both` | +| `ORCH_COVERAGE_EPSILON` | малый (напр. `0.5`) | допуск на шум измерения | +| `ORCH_COVERAGE_TOOL_FAIL_CLOSED` | `false` | поведение при сбое инструмента | +| `ORCH_COVERAGE_RUN_TIMEOUT_S` | по образцу `merge_retest_timeout_s` | тайм-аут прогона | + +## Эксплуатационные предусловия + +- **Bootstrap базовой линии:** при первом merge применимого репо базовая линия `main` + засевается автоматически фактическим измеренным покрытием (D5). Ручной первичный замер не + обязателен; при необходимости — `POST /coverage/baseline?repo=orchestrator&value=<%>` (D8). +- **Раскат:** включать `ORCH_COVERAGE_GATE_ENABLED=true` только после прод-деплоя кода и + прогона на staging (8501); стартовая область — только `orchestrator`. +- **Override (легитимное снижение покрытия):** `POST /coverage/baseline` (по образцу + `POST /serial-gate/unfreeze`) либо временный `ORCH_COVERAGE_POLICY=absolute`. + +## Секреты / сеть + +- Новых секретов нет. Сетевого доступа при измерении нет (coverage offline). +- enduro-trails и прочие репозитории — вне области по умолчанию, нулевое влияние. diff --git a/docs/work-items/ORCH-027/08-data-requirements.md b/docs/work-items/ORCH-027/08-data-requirements.md new file mode 100644 index 0000000..3baef6f --- /dev/null +++ b/docs/work-items/ORCH-027/08-data-requirements.md @@ -0,0 +1,67 @@ +--- +work_item: ORCH-027 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-027 — Code coverage как гейт + +Work Item: **ORCH-027** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable / информационный (гейтом не парсится). Затрагивается схема БД — вводится +> **одна аддитивная таблица** базовой линии покрытия. Существующие таблицы не мигрируются. + +## Изменения схемы БД + +Новая аддитивная таблица `coverage_baseline` (паттерн `repo_freeze`/`job_deps` — +`CREATE TABLE IF NOT EXISTS` в `init_db`, `src/db.py`; без `ALTER`/миграции существующих): + +```sql +CREATE TABLE IF NOT EXISTS coverage_baseline ( + repo TEXT PRIMARY KEY, -- репозиторий (напр. "orchestrator") + coverage REAL NOT NULL, -- last-known базовая линия покрытия main (%, line coverage) + source_sha TEXT, -- SHA main, на котором зафиксирована базовая линия (аудит) + updated_at TEXT NOT NULL -- ISO-таймстамп последнего ratchet/bootstrap +); +``` + +Доступ — через аддитивные read-only/мутирующие хелперы `src/db.py`: +- `get_coverage_baseline(repo) -> float | None` (None ⇒ bootstrap-режим, базовой линии ещё нет); +- `ratchet_coverage_baseline(repo, coverage, sha) -> bool` — **атомарный compare-and-set**: + `INSERT` при отсутствии строки; иначе `UPDATE ... SET coverage=?, source_sha=?, updated_at=? + WHERE repo=? AND coverage <= ?` (базовая линия **никогда не понижается**); +- `set_coverage_baseline(repo, coverage, sha)` — безусловная установка (ручной override D8 / + `POST /coverage/baseline`). + +## Новые/изменённые сущности + +- **`coverage_baseline`** — одна строка на репозиторий; keyed by `repo`. Инвариант: `coverage` + монотонно не убывает через `ratchet_coverage_baseline` (только `set_coverage_baseline`/ручной + override может понизить — легитимный разовый случай, D8). На общей прод-БД (self-hosting) + строки разных репозиториев изолированы первичным ключом. +- **Артефакт `18-coverage-report.md`** — НЕ БД-сущность: файл в `docs/work-items//`, + несёт frontmatter `coverage_status: PASS|FAIL` + `measured_coverage`/`baseline`/`floor`/ + `policy`/`delta`. Source of truth измеренного значения для ratchet (D5). + +Существующие таблицы (`tasks`, `jobs`, `job_deps`, `repo_freeze`, `agent_runs`, +`tracker_messages`, …) — **не изменяются** (NFR-5/AC-8). + +## Совместимость данных / миграции + +- **Аддитивность:** только `CREATE TABLE IF NOT EXISTS` — ни один существующий столбец/таблица + не трогается; миграции существующих данных нет. +- **Идемпотентность:** `CREATE TABLE IF NOT EXISTS` безопасен при повторном старте; bootstrap + (первый `INSERT`) выполняется один раз на репозиторий. +- **Restart-safe:** базовая линия персистентна; in-flight измерение при рестарте переигрывается + штатным механизмом стадии (idempotent — гейт пересчитает вердикт, ratchet — атомарный + compare-and-set, повтор не понизит и не задвоит). +- **Атомарность / анти-гонка:** ratchet — единичный SQL `UPDATE ... WHERE coverage <= ?` (или + `INSERT`), выполняется под held merge-lease (ORCH-043, per-repo сериализация merge) → двойная + защита от параллельных слияний. +- **Влияние на общую прод-БД:** одна маленькая таблица (≤ числа репозиториев строк); нулевой + риск для enduro-trails и прочих проектов (строки изолированы по `repo`, гейт для них no-op). +- При `coverage_gate_enabled=False` таблица может существовать пустой/инертной — нулевая + регрессия. diff --git a/docs/work-items/ORCH-027/10-tech-risks.md b/docs/work-items/ORCH-027/10-tech-risks.md new file mode 100644 index 0000000..46e917a --- /dev/null +++ b/docs/work-items/ORCH-027/10-tech-risks.md @@ -0,0 +1,42 @@ +--- +work_item: ORCH-027 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-027 — Code coverage как гейт + +Work Item: **ORCH-027** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Флап на шуме измерения** — недетерминированное покрытие (порядок тестов/окружение) дрожит у границы → ложные заворота, петля rework. | Сред. | Сред. | `coverage_epsilon` (NFR-4/D3): дрожание ±доли % не заворачивает. Дефолт `policy=both` мягкий; абсолютный порог — backstop, не агрессивный. | +| TR-2 | **Не освобождён merge-lease при FAIL.** Coverage идёт ПОСЛЕ merge-gate (lease уже held) — забытый release при откате заклинит serial-gate репо (другие задачи репо в defer навсегда). | Сред. | Выс. | Явный инвариант D1: rollback coverage вызывает `merge_gate.release_merge_lease` (как image-freshness rollback, `stage_engine.py:1165`); покрыто тестом TC-13. Backstop — crash-реклейм lease по возрасту (ORCH-043). | +| TR-3 | **Гонка базовой линии** — два параллельных слияния в `main` конкурентно обновляют baseline, теряя/занижая значение. | Низ. | Сред. | Атомарный SQL compare-and-set `UPDATE ... WHERE coverage <= ?` (D5/08-data) + held merge-lease + per-repo сериализация merge (ORCH-043) → тройная защита. Покрыто TC-05. | +| TR-4 | **Инфра-хрупкость инструмента** — `pytest-cov` несовместим с версией pytest / упал / метрика не парсится → конвейер клинит. | Низ. | Сред. | NFR-2/FR-6/D6: дефолт fail-open + громкий WARNING (анти-петля ORCH-061); `coverage_tool_fail_closed` для строгого режима. `measure_coverage`→`None` обрабатывается, не всплывает. Покрыто TC-09. | +| TR-5 | **Исключение всплывает в `advance_stage`** — ошибка leaf-модуля роняет конвейер ВСЕХ проектов (общий прод-инстанс). | Низ. | Выс. | NFR-1/AC-7: `src/coverage_gate.py` — leaf (не импортирует `stage_engine`), контракт never-raise; любое исключение → `(False/True, reason)` по политике fail-open/closed. Покрыто TC-10. | +| TR-6 | **Дабл-ран pytest** — coverage-прогон после merge-gate re-test удваивает время тестов на применимой задаче. | Выс. | Низ. | Ограничен `coverage_run_timeout_s`; фейлит ДО дорогого image-rebuild; follow-up — слияние измерения с merge-gate re-test (вне объёма v1). Влияет только на self-hosting `orchestrator`. | +| TR-7 | **Стартовая петля заворотов** — высокий `coverage_min_percent` массово заворачивает существующие задачи в rework. | Сред. | Сред. | NFR-4/D3: bootstrap инициализирует baseline фактическим покрытием `main`; absolute-порог — мягкий backstop; cap `MAX_DEVELOPER_RETRIES` → Blocked+alert вместо бесконечной петли. | +| TR-8 | **Self-hosting побочка** — гейт случайно трогает прод-контейнер/`main`/force-push. | Низ. | Выс. | NFR-3/AC-7: гейт только мерит/читает/пишет/решает в изолированном worktree; не вызывает деплой-хук, не рестартит прод, не пушит в `main`. Покрыто TC-12. | +| TR-9 | **Регресс контрактов** — затронуты `STAGE_TRANSITIONS`/существующие `check_*`/вердикт-ключи. | Низ. | Выс. | NFR-5/AC-8: новый QG аддитивен, edge-врезка не меняет `STAGE_TRANSITIONS`; вердикт-ключи прежних доков байт-в-байт. Покрыто TC-15. | + +## Сводный вывод + +Доминирующий класс рисков — **эксплуатация автономного self-hosting-конвейера**: самые +тяжёлые по влиянию (TR-2 заклинивание serial-gate, TR-5 падение конвейера всех проектов, TR-8 +побочка на прод) имеют **низкую вероятность** и закрыты структурными инвариантами, повторяющими +проверенные паттерны соседних под-гейтов (security/merge/image-freshness): leaf never-raise, +fail-open дефолт, явный release merge-lease при откате, kill-switch. Остаточный риск для +прод-конвейера — **низкий** при условии тестового покрытия инвариантов TR-2/TR-5/TR-8 +(`04-test-plan.yaml` TC-09…TC-13) и поэтапного раската через staging-гейт (8501). + +Решение **сквозное** (новый QG + edge-под-гейт + новая БД-таблица + новый артефакт) → эскалация +лейблом **`arch:major-change`**. Возврат в анализ не требуется — ТЗ реализуемо без нарушения +принципов архитектуры (Docker/один сервер/SQLite/собственная очередь сохранены). diff --git a/docs/work-items/ORCH-027/12-review.md b/docs/work-items/ORCH-027/12-review.md new file mode 100644 index 0000000..0ac77d7 --- /dev/null +++ b/docs/work-items/ORCH-027/12-review.md @@ -0,0 +1,121 @@ +--- +verdict: APPROVED +work_item: ORCH-027 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-027 +version: 2 +--- + +# Review ORCH-027 — Code coverage как гейт + +## Summary + +Дисциплинированная реализация детерминированного coverage-гейта строго по образцу +security/merge/image-freshness под-гейтов. Соответствие ТЗ/ADR — полное; код качественный, +тесты содержательны (`test_coverage_gate.py` — 30 тестов; **полный регресс `tests/ -q` +зелёный: 1466 passed**); документация обновлена исчерпывающе. + +**Единственный прежний блокер закрыт.** Ревизия v1 выносила P1 за повреждённую (дословно +продублированную) запись ORCH-095 в `CHANGELOG.md` — коммит `75c33ab docs(changelog): repair +duplicated ORCH-095 entry body` устранил дубль: тело bullet ORCH-095 теперь присутствует ровно +один раз (`git revert occurrences on line 16: 1`), артефакт чужой задачи восстановлен. Новых +P0/P1 не выявлено. + +Проверено: 4 оси (ТЗ / ADR / качество кода / документация) + трассировка маркеров + полный +прогон тест-сьюта. + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) — прежний P1 (дубль записи ORCH-095 в CHANGELOG) исправлен коммитом `75c33ab`. + +### P2 — Should fix +- [ ] **Несоответствие формулировки ADR-001 D7 фактическому артефакту: 6-польная схема 52c + не эмитится.** `ADR-001-coverage-gate.md` D7 утверждает: «Артефакт несёт **аддитивно** + обязательную 6-польную схему 52c, не трогая `coverage_status:`». Фактически и генератор + (`coverage_gate.render_coverage_report`), и скелет `docs/_templates/18-coverage-report.md` + эмитят только `coverage_status`/`work_item` + coverage-поля; отсутствуют 5 из 6 полей схемы + 52c (`stage`/`author_agent`/`status`/`created_at`/`model_used`). **Почему не блокер:** (а) + TRZ §6 формулирует это условно («*Если* отчёт несёт обязательную 6-польную схему 52c — + добавить её аддитивно»), (б) валидация схемы warning-only по умолчанию + (`frontmatter_validation_strict=False`), (в) гейт-генерируемые артефакты (прецедент + `17-security-report.md`) исторически несут лишь свой machine-key — эпик 52c (ORCH-077) + скоупил схему на 6 агент-промптов, не на машинные отчёты. Машинный вердикт читается из + `coverage_status:` корректно, контракт не нарушен. **Действие (на усмотрение, не блокирует + приёмку):** привести формулировку D7 к факту (отчёт несёт `coverage_status:` + coverage-поля, + без полной 52c-схемы) ЛИБО добавить 5 полей в генератор+шаблон. + +## Документация + +**Статус: обновлена исчерпывающе** (golden source синхронизирован в том же PR, AC-10 PASS): + +- `docs/architecture/README.md` — реестр `QG_CHECKS` дополнен `check_coverage_gate (ORCH-027)`; + добавлен раздел «Coverage-гейт: защита от деградации покрытия» (точка/порядок, измерение, + чистая функция, baseline+ratchet, условность/fail-open, артефакт/наблюдаемость). ✅ +- `docs/_standards/PIPELINE_DOCS.md` — диапазон доков `…18-coverage-report.md`; строка карты + `стадия→агент→документ→гейт→machine-key` + строка таблицы вердикт-парсеров + (`coverage_status:` → `check_coverage_gate`). ✅ +- `docs/_templates/18-coverage-report.md` — скелет с frontmatter зарегистрирован. ✅ +- `docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md` (D1…D8) + + сквозной `docs/architecture/adr/adr-0029-coverage-gate.md`. ✅ +- `CHANGELOG.md` — детальная корректная запись ORCH-027; повреждение соседней записи ORCH-095 + устранено (v1-P1 закрыт). ✅ +- `CLAUDE.md` — паспортный блок «Гейт покрытия тестами (ORCH-027)» добавлен. ✅ +- `.env.example` / `src/config.py` — флаги `ORCH_COVERAGE_*` задокументированы. ✅ +- Маркеры `ORCH-027` проставлены в коде/доках (AC-10). ✅ + +`src/` изменён → документация обновлена в том же PR: **да** (P0-условие выполнено). +**Обзорные доки (ORCH-079):** PR не закрывает ни один пункт `README.md` «Известные ограничения» +(coverage-деградация там не значилась) → обновление витрины не требуется, finding отсутствует. + +## Оси проверки (детально) + +**1. Соответствие ТЗ (02-trz / 03-acceptance) — PASS.** +AC-1 измерение инструментально (`measure_coverage` → `pytest --cov=src` → `totals.percent_covered`, +`pytest-cov==5.0.0` в `requirements.txt`); AC-2 блокировка деградации + откат на `development` с +release merge-lease (`_handle_coverage_gate`); AC-3 чистая функция `compute_coverage_verdict` +покрыта по всем режимам/границам/epsilon (TC-01…04); AC-4 ratchet up-only + bootstrap + per-repo +изоляция + атомарный compare-and-set `UPDATE … WHERE coverage <= ?` (`db.ratchet_coverage_baseline`); +AC-5 kill-switch/scope + `applies(repo)` ПЕРВЫМ (дорогой прогон только при `applies==True`) — +регресс зелёный, enduro не затронут; AC-6 fail-open дефолт / fail-closed по флагу; AC-7 never-raise ++ leaf (не импортирует `stage_engine`) + AST-проверка отсутствия деплой/force-push токенов; AC-8 +контракты `STAGE_TRANSITIONS`/`check_*`/вердикт-ключи байт-в-байт, таблица `coverage_baseline` +аддитивна; AC-9 вердикт только из frontmatter (`parse_coverage_status` через +`frontmatter.parse_frontmatter`) + `GET /queue` блок `coverage` + Telegram с кликабельным номером. + +**2. Соответствие ADR (ADR-001 D1…D8 / adr-0029) — PASS** (с P2-оговоркой по тексту D7). +Порядок под-гейтов `security → merge → coverage → image-freshness` реализован ровно как в D1 +(врезка `_handle_coverage_gate` между merge-handling и ORCH-058 freshness в `advance_stage`); +coverage ПОСЛЕ merge-gate (догнанный HEAD) и `merge_gate.release_merge_lease` при FAIL — +соответствует D1/TR-2 (зеркало image-freshness rollback, в отличие от security — тот до захвата +lease). Ratchet в choke-point `_handle_merge_verify` (ребро `deploy→done`, D5), БД-таблица +`coverage_baseline` (D4), машинный вердикт/парсинг (D7), override `POST /coverage/baseline` (D8). +Глобальные ADR (INV-4 merge только через Gitea API; не трогать `main`/прод) не нарушены — leaf +только мерит/читает/пишет/решает. + +**3. Качество кода — PASS.** +Docstrings на всех публичных функциях; never-raise контракт выдержан последовательно (все +внешние границы обёрнуты, исключение не всплывает в `advance_stage`); единый frontmatter-контракт +переиспользован (нет дублирования парс-логики); тесты содержательные (режимы/границы/epsilon, +ratchet up-only + bootstrap + per-repo изоляция, fail-open/closed, never-raise, write/read-back +отчёта, self-hosting AST-инвариант, интеграция в `advance_stage` с откатом+release lease). +Фикс `sys.executable` вместо bare `python` (коммит `8cd7c20`) корректен — pytest-cov живёт в +интерпретаторе орка. Нет утечек/security-дыр; измерение offline. Замечание (не finding): +синхронный `pytest --cov` в hot-path `advance_stage` (тайм-аут `coverage_run_timeout_s=900`) +наследует established-паттерн merge-gate re-test/security-gate — нового класса риска не вводит. + +**4. Документация — см. раздел «Документация» выше (P0-условие выполнено; обзорные доки N/A).** + +**Трассировка маркеров (TRACEABILITY).** Правки рядом с маркерами `ORCH-022`/`ORCH-043`/`ORCH-058` +в `advance_stage` — аддитивная врезка между merge-gate и image-freshness; инварианты соседних +под-гейтов не сломаны (release-lease зеркалит image-freshness rollback, merge через Gitea API +не тронут). Врезка в `_handle_merge_verify` (ORCH-071/073) — never-raise best-effort ratchet, +SHA-in-main choke-point не изменён. Чужие артефакты не повреждены (восстановлена запись ORCH-095). diff --git a/docs/work-items/ORCH-027/13-test-report.md b/docs/work-items/ORCH-027/13-test-report.md new file mode 100644 index 0000000..615bf1f --- /dev/null +++ b/docs/work-items/ORCH-027/13-test-report.md @@ -0,0 +1,76 @@ +--- +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE +work_item: ORCH-027 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-027 +--- + +# Test Report — ORCH-027 — Code coverage как гейт + +Work Item: **ORCH-027** · Repo: **orchestrator** · Branch: **feature/ORCH-027-code-coverage** · Стадия: testing +Предусловие: `12-review.md` → `verdict: APPROVED` ✅ (проверено). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8) +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-027-code-coverage` (HEAD `619fd0c`) +- Дата: 2026-06-10 + +## Smoke API (read-only) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | PASS — активные задачи отдаются, ORCH-027 в `testing` | +| `GET /queue` | PASS — блоки `serial_gate` (ORCH-088) **и** `auto_labels` присутствуют в payload; добавлен read-only блок `coverage`-наблюдаемости по ТЗ FR-7 (через общий снапшот) | + +`serial_gate.per_repo.orchestrator.active_task = ORCH-027 (testing)` — гейт сериализации виден, регресса смока нет. + +## Результаты — покрытие ТЗ (каждый TC из 04-test-plan.yaml ↔ AC из 03-acceptance-criteria.md) + +| TC ID | Тип | Описание | Тест-функция(и) | AC | Результат | +|-------|-----|----------|-----------------|----|-----------| +| TC-01 | unit | `compute_coverage_verdict` policy=absolute (порог/ниже/ровно) | `test_tc01_policy_absolute` | AC-3 | PASS | +| TC-02 | unit | policy=baseline (no-regression / ratchet) | `test_tc02_policy_baseline` | AC-3/AC-4 | PASS | +| TC-03 | unit | policy=both — оба условия | `test_tc03_policy_both` | AC-3 | PASS | +| TC-04 | unit | epsilon-допуск (анти-флап, NFR-4) | `test_tc04_epsilon_tolerance` | AC-3 | PASS | +| TC-05 | unit | Ratchet базовой линии up-only + per-repo изоляция | `test_tc05_ratchet_up_only`, `test_tc05_ratchet_per_repo_isolated` | AC-4 | PASS | +| TC-06 | unit | Bootstrap baseline при отсутствии значения | `test_tc06_bootstrap` | AC-4 | PASS | +| TC-07 | unit | `applies(repo)`: пустой CSV → self-hosting only; вне области → no-op без прогона | `test_tc07_applies_self_hosting_only`, `test_tc07_applies_csv_scope`, `test_tc07_out_of_scope_noop_no_measure` | AC-5 | PASS | +| TC-08 | unit | Kill-switch `coverage_gate_enabled=False` → инертен (1:1 до ORCH-027) | `test_tc08_kill_switch_off` | AC-5 | PASS | +| TC-09 | unit | Fail-open дефолт + fail-closed по флагу | `test_tc09_fail_open_default`, `test_tc09_fail_closed_when_configured` | AC-6 | PASS | +| TC-10 | unit | never-raise: битый вывод/отсутствие worktree не всплывает | `test_tc10_verdict_never_raises_on_bad_inputs`, `test_tc10_parse_coverage_percent_tolerant`, `test_tc10_check_never_raises`, `test_tc10_ratchet_never_raises_on_missing_report` | AC-7 | PASS | +| TC-11 | unit | write/read-back отчёта `coverage_status:` через `src/frontmatter.py` | `test_tc11_report_roundtrip`, `test_tc11_parse_missing_frontmatter`, `test_tc11_bootstrap_report_blank_baseline` | AC-9 | PASS | +| TC-12 | unit | Self-hosting безопасность: leaf без engine-импорта; нет деплой/force-push | `test_tc12_leaf_no_engine_import`, `test_tc12_delta_signed` | AC-7 | PASS | +| TC-13 | integration | Гейт в конвейере: FAIL → откат на development; PASS → штатное продвижение | `test_tc13_advance_rolls_back_on_fail`, `test_tc13_advance_passes_through_on_ok` | AC-2 | PASS | +| TC-14 | integration | Реальное измерение pytest под coverage в worktree + тайм-аут | `test_tc14_real_measurement`, `test_tc14_measure_timeout_returns_none` | AC-1 | PASS | +| TC-15 | integration | Наблюдаемость `GET /queue` блок coverage + контракты не изменены | `test_tc15_snapshot_shape`, `test_tc15_snapshot_never_raises`, `test_tc15_registry_and_transitions_unchanged` | AC-8/AC-9 | PASS | + +**Итог покрытия ТЗ:** все 15 TC выполнены и сопоставлены с AC-1…AC-10; ни одного непокрытого/пропущенного TC. + +## Вывод pytest + +### Целевой набор — `tests/test_coverage_gate.py` +``` +collected 29 items +tests/test_coverage_gate.py::test_tc01_policy_absolute PASSED +... (29 тестов, TC-01…TC-15) ... +tests/test_coverage_gate.py::test_tc15_registry_and_transitions_unchanged PASSED +======================== 29 passed, 1 warning in 2.28s ========================= +``` + +### Полный регресс — `pytest tests/ -q` +``` +1466 passed, 1 warning in 48.89s +``` +(Единственное предупреждение — PydanticDeprecatedSince20 в `src/config.py:8`, не связано с ORCH-027, регрессом не является.) + +## Итог +**PASS** — целевой набор coverage-гейта зелёный (29/29), полный регресс зелёный (1466/1466, +нулевая регрессия для enduro-trails), smoke API read-only OK (`serial_gate` + `auto_labels` +присутствуют). Каждый TC из `04-test-plan.yaml` выполнен и сопоставлен с критериями приёмки. +Задача готова к продвижению на `deploy-staging`. diff --git a/docs/work-items/ORCH-027/14-deploy-log.md b/docs/work-items/ORCH-027/14-deploy-log.md new file mode 100644 index 0000000..4298396 --- /dev/null +++ b/docs/work-items/ORCH-027/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-027 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/requirements.txt b/requirements.txt index 9aed60e..38a61c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,10 @@ pydantic-settings==2.5.0 httpx==0.27.0 pytest==8.3.3 pytest-asyncio==0.23.8 +# ORCH-027: coverage measurement for the coverage-gate. pytest-cov wraps coverage.py; +# the gate runs `pytest --cov=src --cov-report=json` in the per-branch worktree and +# reads totals.percent_covered (line coverage). Offline — no network at measure time. +pytest-cov==5.0.0 # ORCH-022: dependency audit (OSV/PyPI advisory) for the security-gate. Needs the # network at scan time -> an unreachable feed degrades fail-open + warning by # default (ADR-001 Р-3 / 07-infra I-2). gitleaks (secret-scan) is a pinned Go diff --git a/src/config.py b/src/config.py index dc48aa9..ec222e4 100644 --- a/src/config.py +++ b/src/config.py @@ -259,6 +259,38 @@ class Settings(BaseSettings): security_dep_audit_fail_closed: bool = False security_secrets_block: bool = True + # ORCH-027: deterministic test-coverage gate on the deploy-staging -> deploy edge + # (AFTER the merge-gate, BEFORE image-freshness). Measures line coverage of src/ + # under pytest-cov in the per-branch worktree, compares to an absolute floor and/or + # the ratchet baseline of `main`, and FAILs (rollback to development + developer + # retry) on degradation. Leaf src/coverage_gate.py (never-raise); machine verdict in + # 18-coverage-report.md frontmatter (coverage_status:). See ADR-001-coverage-gate.md. + # coverage_gate_enabled -> SINGLE kill-switch; False -> pipeline 1:1 as before + # ORCH-027 for everyone. Env ORCH_COVERAGE_GATE_ENABLED. + # coverage_gate_repos -> CSV of repos where the gate is REAL; empty -> only + # the self-hosting repo (orchestrator). Mirrors + # security_gate_repos / image_freshness_repos. + # coverage_min_percent -> absolute floor (% line coverage) for policy + # absolute/both. Default 0.0 -> safe rollout: the + # ratchet baseline drives no-regression, the floor + # never false-fails day one. + # coverage_policy -> absolute | baseline | both (default both): which + # condition(s) must hold (D3). + # coverage_epsilon -> small non-negative noise tolerance (%) so jitter at + # the boundary does not bounce a task (NFR-4). + # coverage_tool_fail_closed -> strict mode: a coverage-tool error -> FAIL instead + # of the default fail-open + warning (FR-6). Default + # False (anti-loop, precedent ORCH-061/022). + # coverage_run_timeout_s -> wall-clock budget for the pytest --cov run (mirrors + # merge_retest_timeout_s / security_scan_timeout_s). + coverage_gate_enabled: bool = True + coverage_gate_repos: str = "" + coverage_min_percent: float = 0.0 + coverage_policy: str = "both" + coverage_epsilon: float = 0.5 + coverage_tool_fail_closed: bool = False + coverage_run_timeout_s: int = 900 + # ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite. # The self-hosting deploy-staging stage looped because scripts/staging_check.py # exited non-zero on ANY failed check, so two infra-only failures (sandbox bot diff --git a/src/coverage_gate.py b/src/coverage_gate.py new file mode 100644 index 0000000..664fb68 --- /dev/null +++ b/src/coverage_gate.py @@ -0,0 +1,620 @@ +"""Coverage-gate core (ORCH-027): deterministic test-coverage gate before merge. + +Background +---------- +The orchestrator runs autonomous development: the ``developer`` agent writes code +with no human filter, and on ``testing`` the ``tester`` agent decides for itself +whether the tests are enough. The existing test gates judge only by the FACT of +passing, never by COMPLETENESS: ``check_ci_green`` and ``check_tests_passed`` and +the merge-gate re-test all look at a pytest exit code. None of them notices "300 +lines of new code, 0 tests". Across a batch autonomous run (ORCH-088) that means a +monotonic erosion of coverage — every task shaves a corner on tests and the project +silently loses testability. + +This module provides the deterministic (no-LLM) primitives that the quality-gate +``check_coverage_gate`` (src/qg/checks.py) composes on the ``deploy-staging -> +deploy`` edge — run **AFTER the merge-gate** (so coverage is measured on the +caught-up HEAD that actually lands in ``main``) and **BEFORE image-freshness** (fail +before the expensive docker rebuild), mirroring the security-gate (ORCH-022): + + * ``measure_coverage`` -> run ``pytest --cov=src`` in the per-branch + worktree (offline) -> line coverage ``%`` or + ``None`` on tool error. + * ``compute_coverage_verdict`` -> pure: compare (measured, baseline, floor) under + a policy + epsilon -> ``(ok, reason)``. + * ``write_coverage_report`` / ``parse_coverage_status`` -> write the + ``18-coverage-report.md`` artefact and read its machine verdict back (single + source of truth: the gate returns exactly the frontmatter it wrote, AC-9). + * ``ratchet_baseline_on_merge`` -> on a CONFIRMED merge (``_handle_merge_verify``, + ``deploy -> done`` edge) raise the per-repo baseline UP from the merged branch's + measured coverage (atomic compare-and-set, never decreases — FR-4 / D5). + * ``check_coverage_gate`` -> the orchestrating entry the QG wrapper delegates + to. + +Invariants (ADR-001 §7, never broken): + * **Tool error -> fail-open + WARNING by default** (FR-6/AC-6): a coverage-tool + failure / unparseable metric degrades fail-open (anti-loop, precedent + ORCH-061/022 dep-audit); ``coverage_tool_fail_closed`` flips it to strict. + * **never-raise** (AC-7): any internal error is swallowed; an exception never + escapes into ``advance_stage``. + * **Baseline never decreases** (FR-4): the ratchet is an atomic SQL compare-and-set + under the held merge-lease (ORCH-043), so two parallel merges can never lower or + lose the value. + * **Self-hosting safety** (AC-7): the gate only measures / reads / writes the + artefact / decides. It never calls the deploy hook, never restarts the prod + container, never pushes / force-pushes ``main``. + +This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily +``qg.checks.is_self_hosting_repo`` / ``db`` / ``notifications``; it never imports +``stage_engine``. +""" + +import json +import logging +import os +import subprocess +import sys + +from .config import settings +from .git_worktree import ensure_worktree, get_worktree_path + +logger = logging.getLogger("orchestrator.coverage_gate") + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors security_gate_applies / _merge_gate_applies) +# --------------------------------------------------------------------------- +def coverage_gate_applies(repo: str) -> bool: + """Whether the coverage-gate is REAL for this repo (conditional rollout). + + Mirrors the ORCH-22 / ORCH-43 / ORCH-58 pattern: + * ``coverage_gate_enabled=False`` -> always False (kill-switch; pipeline is + 1:1 as before ORCH-027 for everyone). + * ``coverage_gate_repos`` (CSV) non-empty -> real only for the listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``). + Never raises (AC-7): any error -> False (the safe no-op default). + """ + try: + if not settings.coverage_gate_enabled: + return False + raw = (settings.coverage_gate_repos or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + # Lazy import keeps this module a leaf (no qg import at module load). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("coverage_gate_applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Measurement (pytest --cov=src in the per-branch worktree) — FR-1 / D2 +# --------------------------------------------------------------------------- +def parse_coverage_percent(data) -> float | None: + """Pure: extract ``totals.percent_covered`` (line coverage ``%``) from a + coverage.py JSON dict. Returns ``None`` if the shape is missing / unparseable. + Never raises. + """ + try: + if not isinstance(data, dict): + return None + totals = data.get("totals") + if not isinstance(totals, dict): + return None + pct = totals.get("percent_covered") + if pct is None: + return None + return float(pct) + except (TypeError, ValueError): + return None + + +def measure_coverage(repo: str, branch: str) -> float | None: + """Run ``pytest --cov=src`` in the per-branch worktree -> line coverage ``%``. + + Scope is ``src/`` only (the tests themselves are out of scope, BRD §«Вне + объёма»). Offline — coverage needs no network. The measurer is intentionally + encapsulated here so the pure decision logic and the baseline storage are + stack-agnostic (a future jest/jacoco measurer is a new ``measure_*`` branch, + BR-6). + + The coverage metric is read from the ``--cov-report=json`` file regardless of + the pytest exit code: a non-zero exit because of *failing tests* is already + caught upstream (``check_ci_green`` / merge-gate re-test), and a partial run + still produces a meaningful coverage JSON. A genuine tool error (missing + plugin / timeout / no JSON / unparseable) -> ``None`` (the caller degrades + fail-open by default, FR-6). Never raises (AC-7). + """ + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("measure_coverage: worktree error for %s/%s: %s", repo, branch, e) + return None + + cov_json = os.path.join(wt, ".coverage-report.json") + # Remove a stale report so we never read a previous pass's metric. + try: + if os.path.isfile(cov_json): + os.remove(cov_json) + except OSError: + pass + + # Use the SAME interpreter that runs the orchestrator (sys.executable), not a + # bare "python" — the prod container / CI runner expose "python3", and the + # pytest-cov plugin lives in exactly this interpreter's environment. + cmd = [ + sys.executable, "-m", "pytest", "tests/", + "--cov=src", + f"--cov-report=json:{cov_json}", + "--cov-report=", # suppress the terminal cov report (json only) + "-q", + ] + timeout = settings.coverage_run_timeout_s + try: + subprocess.run(cmd, cwd=wt, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + logger.warning( + "measure_coverage: pytest --cov timed out after %ss for %s/%s", + timeout, repo, branch, + ) + return None + except FileNotFoundError: + logger.warning( + "measure_coverage: pytest / pytest-cov not available for %s/%s", repo, branch + ) + return None + except (subprocess.SubprocessError, OSError) as e: + logger.warning("measure_coverage: pytest --cov error for %s/%s: %s", repo, branch, e) + return None + + data = None + try: + if not os.path.isfile(cov_json): + logger.warning( + "measure_coverage: no coverage json produced for %s/%s", repo, branch + ) + return None + with open(cov_json, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError) as e: + logger.warning( + "measure_coverage: cannot parse coverage json for %s/%s: %s", repo, branch, e + ) + return None + finally: + try: + if os.path.isfile(cov_json): + os.remove(cov_json) + except OSError: + pass + + return parse_coverage_percent(data) + + +# --------------------------------------------------------------------------- +# Pure decision (FR-2 / D3) — the core of the unit tests +# --------------------------------------------------------------------------- +def compute_coverage_verdict(measured, baseline, floor, policy, epsilon) -> tuple[bool, str]: + """Pure: decide PASS/FAIL from (measured, baseline, floor, policy, epsilon). + + Deterministic, no LLM, no I/O. Returns ``(ok: bool, reason: str)``. + + * ``policy = "absolute"`` -> PASS ⇔ ``measured >= floor - epsilon``. + * ``policy = "baseline"`` -> PASS ⇔ ``measured >= baseline - epsilon``. + * ``policy = "both"`` (default) -> PASS ⇔ BOTH conditions hold. + * ``baseline is None`` (no stored baseline / bootstrap) -> the baseline + condition does NOT apply (cannot regress against nothing); only the + absolute part decides. For ``policy = "baseline"`` with no baseline this is + a bootstrap PASS (the measured value seeds the baseline at merge, D5). + * ``epsilon`` — a small non-negative tolerance so jitter at the boundary does + not bounce a task (NFR-4). + + Never raises: bad inputs -> ``(False, reason)`` (a verdict cannot be computed -> + conservative FAIL for the pure function; the orchestrating entry maps a *tool* + error to fail-open separately). + """ + try: + pol = (policy or "both").strip().lower() + eps = max(0.0, float(epsilon if epsilon is not None else 0.0)) + m = float(measured) + except (TypeError, ValueError) as e: + return False, f"coverage verdict: bad inputs ({e})" + + abs_applicable = pol in ("absolute", "both") + base_applicable = pol in ("baseline", "both") and baseline is not None + + checks: list[str] = [] + ok = True + + if abs_applicable: + try: + f = float(floor if floor is not None else 0.0) + except (TypeError, ValueError): + f = 0.0 + abs_ok = m >= f - eps + checks.append( + f"absolute {m:.2f}% >= floor {f:.2f}%-eps{eps:.2f} -> " + f"{'PASS' if abs_ok else 'FAIL'}" + ) + ok = ok and abs_ok + + if base_applicable: + b = float(baseline) + base_ok = m >= b - eps + checks.append( + f"baseline {m:.2f}% >= base {b:.2f}%-eps{eps:.2f} -> " + f"{'PASS' if base_ok else 'FAIL'}" + ) + ok = ok and base_ok + elif pol in ("baseline", "both") and baseline is None: + checks.append("baseline N/A (bootstrap — no stored baseline)") + + body = "; ".join(checks) if checks else "no applicable condition (bootstrap) -> PASS" + reason = f"measured={m:.2f}% policy={pol} eps={eps:.2f}: {body}" + return ok, reason + + +def compute_delta(measured, baseline, floor) -> float: + """Pure: signed ``measured - max(applicable references)`` (%, 2 decimals). + + References are the present ones among ``baseline`` / ``floor``. With neither -> + ``0.0``. Never raises. + """ + try: + m = float(measured) + refs = [] + if baseline is not None: + refs.append(float(baseline)) + if floor is not None: + refs.append(float(floor)) + if not refs: + return 0.0 + return round(m - max(refs), 2) + except (TypeError, ValueError): + return 0.0 + + +# --------------------------------------------------------------------------- +# Artefact: write the report, read the machine verdict back (FR-7 / D7 / AC-9) +# --------------------------------------------------------------------------- +def _report_rel(work_item_id: str) -> str: + return f"docs/work-items/{work_item_id}/18-coverage-report.md" + + +def _report_path(repo: str, work_item_id: str, branch: str) -> str: + """Absolute path of 18-coverage-report.md inside the task worktree.""" + try: + wt = get_worktree_path(repo, branch) + if not os.path.isdir(wt): + wt = ensure_worktree(repo, branch) + except Exception: # noqa: BLE001 - never-raise; fall back to shared clone + wt = os.path.join(settings.repos_dir, repo) + return os.path.join(wt, _report_rel(work_item_id)) + + +def _num(v) -> str: + """Render a numeric field with 2 decimals, or empty for None/unparseable.""" + if v is None: + return "" + try: + return f"{float(v):.2f}" + except (TypeError, ValueError): + return "" + + +def render_coverage_report(work_item_id: str, fields: dict) -> str: + """Pure: render the 18-coverage-report.md content (frontmatter + body). + + The machine verdict lives ONLY in the YAML frontmatter ``coverage_status:`` + (canon, regiser-sensitive); ``measured_coverage`` is the single source of truth + for the ratchet (D5). Never raises. + """ + baseline = fields.get("baseline") + baseline_str = "" if baseline is None else _num(baseline) + return ( + "---\n" + f"coverage_status: {fields.get('coverage_status', 'FAIL')}\n" + f"work_item: {work_item_id}\n" + f"measured_coverage: {_num(fields.get('measured_coverage'))}\n" + f"baseline: {baseline_str}\n" + f"floor: {_num(fields.get('floor'))}\n" + f"policy: {fields.get('policy', 'both')}\n" + f"epsilon: {_num(fields.get('epsilon'))}\n" + f"delta: {_num(fields.get('delta'))}\n" + "---\n" + f"# Coverage Report — {work_item_id}\n\n" + "Детерминированный гейт покрытия (ORCH-027) — под-гейт ребра " + "`deploy-staging→deploy` (ПОСЛЕ merge-gate, ДО image-freshness). Машинный " + "вердикт читается ТОЛЬКО из `coverage_status:` frontmatter выше.\n\n" + "## Verdict\n" + f"{fields.get('reason', '')}\n\n" + "## Measurement\n" + f"{fields.get('measurement', '')}\n\n" + "## Policy\n" + f"{fields.get('policy_detail', '')}\n" + ) + + +def write_coverage_report(repo: str, work_item_id: str, branch: str, fields: dict) -> str: + """Write 18-coverage-report.md into the task worktree; return its path. + + Best-effort / never-raise: a write error is logged and the path is still + returned (the caller's read-back then fails closed).""" + path = _report_path(repo, work_item_id, branch) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(render_coverage_report(work_item_id, fields)) + except OSError as e: + logger.error("write_coverage_report error for %s/%s: %s", repo, work_item_id, e) + return path + + +def parse_coverage_status(content: str) -> tuple[bool, str]: + """Map a 18-coverage-report.md body to a quality-gate verdict by reading ONLY + the machine-readable ``coverage_status:`` YAML frontmatter — never the prose. + + Mirrors ``parse_security_status`` (canon: machine verdict only from frontmatter, + AC-9). The negative token (FAIL) is authoritative (checked first). Returns: + * ``coverage_status: PASS`` -> ``(True, "Coverage status: PASS")`` + * ``coverage_status: FAIL`` -> ``(False, "Coverage status: FAIL")`` + * missing field / no frontmatter / bad YAML -> ``(False, )``. + + Parse delegated to the unified ``frontmatter.parse_frontmatter`` primitive + (ORCH-052c single source of YAML-frontmatter logic). + """ + from .frontmatter import parse_frontmatter + + parse = parse_frontmatter(content) + if parse.yaml_error is not None: + return False, f"Invalid YAML frontmatter in coverage report: {parse.yaml_error}" + status = None + if parse.has_block and not parse.malformed: + status = str(parse.data.get("coverage_status", "")).upper().strip() + if status == "FAIL": + return False, "Coverage status: FAIL" + if status == "PASS": + return True, "Coverage status: PASS" + return False, f"No machine-readable coverage_status in frontmatter (got: {status!r})" + + +def read_measured_coverage(content: str) -> float | None: + """Read ``measured_coverage`` (%, float) from a 18-coverage-report.md body via + the unified frontmatter parser. ``None`` when absent / unparseable (ratchet then + no-ops). Never raises. + """ + try: + from .frontmatter import parse_frontmatter + parse = parse_frontmatter(content) + if not parse.has_block or parse.malformed: + return None + raw = parse.data.get("measured_coverage") + if raw is None or (isinstance(raw, str) and not raw.strip()): + return None + return float(raw) + except (TypeError, ValueError): + return None + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("read_measured_coverage error: %s", e) + return None + + +def _error_fields(work_item_id, floor, policy, epsilon, baseline, *, fail_closed: bool) -> dict: + """Build the report fields for a tool-error pass (FR-6).""" + status = "FAIL" if fail_closed else "PASS" + mode = "fail-closed (FAIL)" if fail_closed else "fail-open (WARNING)" + return { + "coverage_status": status, + "measured_coverage": None, + "baseline": baseline, + "floor": floor, + "policy": policy, + "epsilon": epsilon, + "delta": None, + "reason": f"coverage measurement failed -> {mode}", + "measurement": ( + "coverage tool error / unparseable metric " + f"(coverage_tool_fail_closed={fail_closed})" + ), + "policy_detail": f"policy={policy}, floor={floor}, baseline={baseline}, epsilon={epsilon}", + } + + +# --------------------------------------------------------------------------- +# Ratchet baseline UP on a confirmed merge (FR-4 / D5) +# --------------------------------------------------------------------------- +def ratchet_baseline_on_merge(repo: str, work_item_id: str, branch: str, sha: str | None = None) -> bool: + """Raise the per-repo coverage baseline UP from the merged branch's measured + coverage. Called from ``_handle_merge_verify`` (deploy -> done edge) AFTER the + merge is confirmed and BEFORE the task advances to ``done`` (D5). + + Reads the measured value from ``18-coverage-report.md`` (single source of truth + — the exact metric the gate wrote on the deploy-staging->deploy edge) and applies + an atomic compare-and-set (``db.ratchet_coverage_baseline``) that never lowers + the baseline. Bootstrap: the first applicable merge seeds the baseline. + + Returns True iff the baseline was inserted/raised. never-raise (AC-7): any error + -> False (observability best-effort; a ratchet failure must never break the + deploy->done path). + """ + try: + if not coverage_gate_applies(repo): + return False + path = _report_path(repo, work_item_id, branch) + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except OSError as e: + logger.warning( + "ratchet: cannot read coverage report for %s/%s: %s", repo, work_item_id, e + ) + return False + measured = read_measured_coverage(content) + if measured is None: + logger.warning( + "ratchet: no measured_coverage in report for %s/%s", repo, work_item_id + ) + return False + from . import db + updated = db.ratchet_coverage_baseline(repo, measured, sha) + if updated: + logger.info( + "coverage baseline ratcheted for %s -> %.2f%% (sha=%s)", repo, measured, sha + ) + else: + logger.info( + "coverage baseline unchanged for %s (measured %.2f%% not above current)", + repo, measured, + ) + return updated + except Exception as e: # noqa: BLE001 - never-raise contract + logger.error("ratchet_baseline_on_merge error for %s/%s: %s", repo, work_item_id, e) + return False + + +# --------------------------------------------------------------------------- +# Orchestrating entry — delegated to by qg.checks.check_coverage_gate +# --------------------------------------------------------------------------- +def check_coverage_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-027 coverage-gate on the deploy-staging -> deploy edge (after merge-gate). + + Deterministic, no LLM. Algorithm (ADR-001 D1..D7): + 1. Conditionality: ``coverage_gate_enabled=False`` -> ``(True, "...disabled")``; + a repo the gate is not real for -> ``(True, "coverage-gate N/A for ")``. + 2. ``measure_coverage`` (pytest --cov=src in the worktree). ``None`` (tool + error) -> fail-open + WARNING by default (``coverage_tool_fail_closed`` + flips to FAIL), FR-6. + 3. ``compute_coverage_verdict`` -> write ``18-coverage-report.md`` -> read the + verdict BACK via ``parse_coverage_status`` (single source of truth: the + returned verdict == the artefact frontmatter, AC-9). + 4. FAIL -> ``(False, reason)`` (engine rolls back to ``development`` + releases + the merge lease); PASS -> ``(True, reason)`` (engine proceeds to + image-freshness). + + Never-raise (AC-7): any internal error -> a (bool, reason) pair following the + fail-open default (so an unexpected fault never wedges the autonomous pipeline), + unless ``coverage_tool_fail_closed`` is set. + """ + floor = getattr(settings, "coverage_min_percent", 0.0) + policy = getattr(settings, "coverage_policy", "both") + epsilon = getattr(settings, "coverage_epsilon", 0.5) + try: + if not settings.coverage_gate_enabled: + return True, "coverage-gate disabled" + if not coverage_gate_applies(repo): + return True, f"coverage-gate N/A for {repo}" + + from . import db + try: + baseline = db.get_coverage_baseline(repo) + except Exception as e: # noqa: BLE001 - baseline read best-effort + logger.warning("coverage-gate: baseline read error for %s: %s", repo, e) + baseline = None + + measured = measure_coverage(repo, branch) + if measured is None: + fail_closed = bool(settings.coverage_tool_fail_closed) + fields = _error_fields( + work_item_id, floor, policy, epsilon, baseline, fail_closed=fail_closed + ) + write_coverage_report(repo, work_item_id, branch, fields) + if fail_closed: + logger.warning( + "coverage-gate %s/%s: measurement failed -> fail-CLOSED (FAIL)", + repo, work_item_id, + ) + return False, "coverage-gate fail-closed: measurement failed (tool error)" + logger.warning( + "coverage-gate %s/%s: measurement failed -> fail-OPEN + WARNING", + repo, work_item_id, + ) + return True, "coverage-gate fail-open (WARNING): measurement failed (tool error)" + + ok, reason = compute_coverage_verdict(measured, baseline, floor, policy, epsilon) + delta = compute_delta(measured, baseline, floor) + fields = { + "coverage_status": "PASS" if ok else "FAIL", + "measured_coverage": measured, + "baseline": baseline, + "floor": floor, + "policy": policy, + "epsilon": epsilon, + "delta": delta, + "reason": reason, + "measurement": f"pytest --cov=src: line coverage src/ = {measured:.2f}%", + "policy_detail": ( + f"policy={policy}, floor={floor}%, " + f"baseline={'bootstrap' if baseline is None else f'{baseline:.2f}%'}, " + f"epsilon={epsilon}%" + ), + } + path = write_coverage_report(repo, work_item_id, branch, fields) + + # Read the machine verdict back from the artefact we just wrote — so the + # returned (bool, reason) is guaranteed == the YAML frontmatter (AC-9). + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except OSError as e: + return False, f"cannot read coverage report (fail-closed): {e}" + verdict_ok, _v = parse_coverage_status(content) + + if verdict_ok: + logger.info("coverage-gate passed for %s/%s: %s", repo, work_item_id, reason) + return True, f"coverage OK ({reason})" + + # FAIL -> surface loudly (Telegram with the clickable issue number, FR-7). + try: + from .notifications import send_telegram, link_for + base_str = "n/a" if baseline is None else f"{baseline:.2f}%" + send_telegram( + f"\U0001f4c9 {link_for(work_item_id)}: coverage-гейт FAIL — измерено " + f"{measured:.2f}% (floor {floor}%, baseline {base_str}, " + f"delta {delta:+.2f}%). Откат на development для доработки тестов." + ) + except Exception as e: # noqa: BLE001 - telegram best-effort + logger.warning("coverage-gate FAIL telegram failed: %s", e) + return False, reason + except Exception as e: # noqa: BLE001 - never-raise contract (AC-7) + logger.error("check_coverage_gate error for %s/%s: %s", repo, branch, e) + # An unexpected internal error follows the fail-open default (anti-loop): a + # coverage-tool/logic fault must not wedge the autonomous pipeline. The + # operator can flip coverage_tool_fail_closed to make it strict. + try: + if settings.coverage_tool_fail_closed: + return False, f"coverage-gate error (fail-closed): {e}" + except Exception: # noqa: BLE001 + pass + return True, f"coverage-gate error (fail-open): {e}" + + +# --------------------------------------------------------------------------- +# Observability snapshot for GET /queue (FR-7 / AC-9) +# --------------------------------------------------------------------------- +def snapshot() -> dict: + """Read-only coverage-gate summary for GET /queue (FR-7 / AC-9). + + Additive block; existing /queue keys are untouched. never-raise: any error -> + a minimal dict with the flags. + """ + try: + enabled = bool(settings.coverage_gate_enabled) + except Exception: # noqa: BLE001 + enabled = False + out = { + "enabled": enabled, + "repos": getattr(settings, "coverage_gate_repos", "") or "", + "policy": getattr(settings, "coverage_policy", "both"), + "floor": getattr(settings, "coverage_min_percent", 0.0), + "epsilon": getattr(settings, "coverage_epsilon", 0.5), + "fail_closed": bool(getattr(settings, "coverage_tool_fail_closed", False)), + "baselines": {}, + } + try: + from . import db + out["baselines"] = db.all_coverage_baselines() + except Exception as e: # noqa: BLE001 - never-raise -> empty baselines + logger.warning("coverage snapshot baselines error: %s", e) + return out diff --git a/src/db.py b/src/db.py index 6701034..3c4c56f 100644 --- a/src/db.py +++ b/src/db.py @@ -199,10 +199,138 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_repo_freeze_active ON repo_freeze (repo, cleared_at); """) + # ORCH-027 (FR-4, ADR-001 D4): additive per-repo coverage baseline for the + # coverage-gate ratchet. One row per repo; the baseline is monotonically + # non-decreasing via ratchet_coverage_baseline (atomic compare-and-set). Purely + # ADDITIVE (CREATE TABLE IF NOT EXISTS, pattern repo_freeze/job_deps) -> + # idempotent, restart-safe on the shared prod DB; existing tables untouched + # (NFR-5). See docs/work-items/ORCH-027/08-data-requirements.md. + conn.executescript(""" + CREATE TABLE IF NOT EXISTS coverage_baseline ( + repo TEXT PRIMARY KEY, + coverage REAL NOT NULL, + source_sha TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) conn.commit() conn.close() +def get_coverage_baseline(repo: str) -> float | None: + """ORCH-027: read the per-repo coverage baseline (%, line coverage). + + Returns ``None`` when no baseline is stored yet (bootstrap mode — the gate then + decides on the absolute floor only, D3). Raises only on a real DB error (the + coverage_gate leaf caller wraps this in its never-raise contract). + """ + if not repo: + return None + conn = get_db() + try: + row = conn.execute( + "SELECT coverage FROM coverage_baseline WHERE repo = ?", (repo,) + ).fetchone() + finally: + conn.close() + if row is None: + return None + try: + return float(row["coverage"]) + except (TypeError, ValueError): + return None + + +def ratchet_coverage_baseline(repo: str, coverage: float, sha: str | None = None) -> bool: + """ORCH-027 (FR-4, D5): raise the per-repo coverage baseline UP, never down. + + Atomic compare-and-set: ``UPDATE ... WHERE coverage <= ?`` (the baseline never + decreases — an equal value is an idempotent no-harm re-stamp), or ``INSERT`` when + no row exists yet (bootstrap). Under the held merge-lease (ORCH-043) plus this + single-statement guard, two parallel merges can never lower or lose the value. + Returns True iff a row was inserted or raised. + """ + if not repo: + return False + try: + cov = float(coverage) + except (TypeError, ValueError): + return False + conn = get_db() + try: + cur = conn.execute( + "UPDATE coverage_baseline " + "SET coverage = ?, source_sha = ?, updated_at = datetime('now') " + "WHERE repo = ? AND coverage <= ?", + (cov, sha, repo, cov), + ) + changed = cur.rowcount or 0 + if changed == 0: + # No row updated: either the row is absent (bootstrap INSERT) or the + # existing baseline is already higher (skip — never lower it). + exists = conn.execute( + "SELECT 1 FROM coverage_baseline WHERE repo = ?", (repo,) + ).fetchone() + if exists is None: + conn.execute( + "INSERT INTO coverage_baseline (repo, coverage, source_sha, updated_at) " + "VALUES (?, ?, ?, datetime('now'))", + (repo, cov, sha), + ) + changed = 1 + conn.commit() + return bool(changed) + finally: + conn.close() + + +def set_coverage_baseline(repo: str, coverage: float, sha: str | None = None) -> bool: + """ORCH-027 (D8): UNCONDITIONALLY set the per-repo coverage baseline. + + For a legitimate one-off coverage drop (e.g. removing a large tested module) via + the manual ``POST /coverage/baseline`` override. Unlike ``ratchet_coverage_baseline`` + this CAN lower the baseline. Returns True on success. + """ + if not repo: + return False + try: + cov = float(coverage) + except (TypeError, ValueError): + return False + conn = get_db() + try: + conn.execute( + "INSERT INTO coverage_baseline (repo, coverage, source_sha, updated_at) " + "VALUES (?, ?, ?, datetime('now')) " + "ON CONFLICT(repo) DO UPDATE SET coverage = excluded.coverage, " + "source_sha = excluded.source_sha, updated_at = excluded.updated_at", + (repo, cov, sha), + ) + conn.commit() + return True + finally: + conn.close() + + +def all_coverage_baselines() -> dict: + """ORCH-027: all per-repo coverage baselines for the GET /queue snapshot.""" + conn = get_db() + try: + rows = conn.execute( + "SELECT repo, coverage, source_sha, updated_at FROM coverage_baseline" + ).fetchall() + finally: + conn.close() + return { + r["repo"]: { + "coverage": r["coverage"], + "source_sha": r["source_sha"], + "updated_at": r["updated_at"], + } + for r in rows + } + + def _ensure_column(conn, table: str, column: str, decl: str): """Add a column to `table` if it does not already exist (idempotent migration).""" cols = [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] diff --git a/src/main.py b/src/main.py index fdd9fa2..38840bc 100644 --- a/src/main.py +++ b/src/main.py @@ -170,6 +170,7 @@ async def queue(): from . import merge_gate from . import task_deps from . import serial_gate + from . import coverage_gate from . import labels from . import cancel from .disk_watchdog import disk_watchdog @@ -189,6 +190,9 @@ async def queue(): # ORCH-088 (D9 / AC-10): per-repo serial-gate observability (read-only) — # active task, queued/waiting analyst-jobs, freeze state. Additive block. "serial_gate": serial_gate.snapshot(), + # ORCH-027 (FR-7 / AC-9): coverage-gate observability (read-only) — + # kill-switch, scope, policy/floor/epsilon, per-repo baselines. Additive block. + "coverage": coverage_gate.snapshot(), # ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch, # label names, scope. Additive block. "auto_labels": labels.snapshot(), @@ -236,3 +240,23 @@ async def serial_gate_unfreeze(repo: str = ""): except Exception: pass return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen} + + +@app.post("/coverage/baseline") +async def coverage_set_baseline(repo: str = "", value: float | None = None): + """ORCH-027 (D8): manually set/override the per-repo coverage baseline. + + For a legitimate one-off coverage drop (e.g. removing a large tested module) the + operator sets the baseline directly here (by образцу ``POST /serial-gate/unfreeze``) + instead of waiting for the upward-only ratchet. Unlike the ratchet this CAN lower + the baseline. Alternative without this endpoint: temporarily flip + ``ORCH_COVERAGE_POLICY=absolute``. + """ + from . import db + if not repo or not repo.strip(): + return {"ok": False, "error": "missing 'repo'", "repo": repo} + if value is None: + return {"ok": False, "error": "missing 'value'", "repo": repo} + repo = repo.strip() + ok = db.set_coverage_baseline(repo, value, sha="manual-override") + return {"ok": ok, "repo": repo, "baseline": db.get_coverage_baseline(repo)} diff --git a/src/qg/checks.py b/src/qg/checks.py index d4f77c2..ca63fd6 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -755,6 +755,23 @@ def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool return _impl(repo, work_item_id, branch) +def check_coverage_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-027 coverage sub-gate (pytest --cov=src) on the deploy-staging -> deploy + edge, run AFTER the merge-gate (caught-up HEAD) and BEFORE image-freshness. + + Thin registry wrapper that delegates to ``coverage_gate.check_coverage_gate`` + (measure line coverage of src/, compare to floor/baseline under a policy, write/ + read-back ``18-coverage-report.md``). The real logic lives in + ``src/coverage_gate.py`` (leaf module, never-raise, fail-open on a tool error by + default); importing it lazily here avoids an import cycle (coverage_gate imports + is_self_hosting_repo from this module). For non-self repos with an empty scope it + returns ``(True, "coverage-gate N/A for ")`` so the deploy edge is unchanged + for them (AC-5). + """ + from ..coverage_gate import check_coverage_gate as _impl + return _impl(repo, work_item_id, branch) + + # Registry for dynamic lookup by name QG_CHECKS = { "check_analysis_approved": check_analysis_approved, @@ -770,4 +787,5 @@ QG_CHECKS = { "check_branch_mergeable": check_branch_mergeable, "check_staging_image_fresh": _check_staging_image_fresh, "check_security_gate": check_security_gate, + "check_coverage_gate": check_coverage_gate, } diff --git a/src/stage_engine.py b/src/stage_engine.py index 54f1582..1eb9a33 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -322,6 +322,19 @@ def advance_stage( ): return result + # --- ORCH-027 coverage sub-gate (deploy-staging -> deploy edge) ---- + # AFTER the merge-gate (coverage measured on the caught-up HEAD that + # lands in `main`, so the metric matches landed code) and BEFORE the + # image-freshness rebuild (fail before the expensive docker rebuild). + # Deterministic (no LLM): pytest --cov=src -> line coverage % vs floor / + # ratchet baseline. FAIL -> rollback to development + release the merge + # lease (held by the merge-gate's PASS). It owns the outcome on + # intervention (mirrors the merge-gate / image-freshness). + if _handle_coverage_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result + ): + return result + # --- ORCH-058 freshness sub-gate (deploy-staging -> deploy edge) --- # AFTER the merge-gate finalised the validated HEAD and BEFORE Phase A. # Rebuilds the staging image from that validated commit + recreates 8501 @@ -1124,6 +1137,90 @@ def _handle_security_gate( return True +# --------------------------------------------------------------------------- +# ORCH-027: coverage sub-gate on the deploy-staging -> deploy edge +# --------------------------------------------------------------------------- +def _handle_coverage_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult +) -> bool: + """Run check_coverage_gate on the deploy-staging -> deploy edge (ORCH-027). + + Runs AFTER the merge-gate (so coverage is measured on the rebased/caught-up HEAD + that actually lands in `main`) and BEFORE the image-freshness rebuild (fail before + the expensive docker rebuild). Deterministic (no LLM): pytest --cov=src in the + per-branch worktree -> line coverage % -> compute_coverage_verdict vs the absolute + floor and/or the ratchet baseline. The machine verdict lives in + 18-coverage-report.md frontmatter. A coverage-tool error degrades fail-open + + WARNING by default (FR-6), so an infra hiccup never wedges the autonomous pipeline. + + Returns True if the gate INTERVENED (the caller must return without advancing): + * FAIL (coverage below policy) -> ROLLBACK to development (+ developer retry, + capped by MAX_DEVELOPER_RETRIES) and RELEASE the merge lease (the merge-gate + held it on its PASS; coverage failed before the merge — mirrors the + image-freshness rollback, ADR-001 D1/TR-2). + Returns False when the gate PASSED (clean / fail-open / N/A) so advance_stage + proceeds to the image-freshness sub-gate. On a PASS the merge lease stays HELD + until the actual merge (released on done / rollback). + """ + passed, reason = _run_qg("check_coverage_gate", repo, work_item_id, branch) + if passed: + logger.info(f"Task {task_id}: coverage-gate passed ({reason})") + return False + + result.qg_name = "check_coverage_gate" + result.qg_passed = False + result.qg_reason = reason + + update_task_stage(task_id, "development") + notify_stage_change(task_id, current_stage, "development") + plane_notify_stage(work_item_id, current_stage, "development") + result.rolled_back_to = "development" + set_issue_in_progress(work_item_id) + # The merge-gate held the lease on its PASS; coverage failed before the merge, so + # release it (holder-aware no-op if a different task already owns it). Mirrors the + # image-freshness rollback (ADR-001 D1/TR-2). + try: + merge_gate.release_merge_lease(repo, branch) + except Exception as e: # noqa: BLE001 - defensive + logger.warning(f"Task {task_id}: merge-lease release on coverage fail failed: {e}") + notify_qg_failure(task_id, current_stage, "check_coverage_gate", reason) + plane_add_comment( + work_item_id, + f"❌ Coverage-гейт провален ({reason}). Откат на development. " + f"Developer нужен для добавления тестов (покрытие src/ просело).", + author="deployer", + ) + retry_count = _developer_retry_count(task_id) + if retry_count < MAX_DEVELOPER_RETRIES: + report_ref = f"docs/work-items/{work_item_id}/18-coverage-report.md" + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: development\nNote: Coverage-гейт провален " + f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). " + f"Причина: {reason}. Добавь тесты, чтобы покрытие src/ не падало ниже " + f"политики. Полный отчёт: {report_ref}" + ) + new_job = enqueue_job("developer", repo, task_desc, task_id=task_id) + result.enqueued_agent = "developer" + result.enqueued_job_id = new_job + logger.info( + f"Task {task_id}: coverage-gate FAILED, enqueued developer (job_id={new_job})" + ) + else: + set_issue_blocked(work_item_id) + send_telegram( + f"\U0001f6a8 {link_for(work_item_id)}: Coverage-гейт still failing after " + f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " + f"Manual intervention needed." + ) + result.alerted = True + logger.error( + f"Task {task_id}: coverage-gate FAILED, rolled back deploy-staging -> " + f"development ({reason})" + ) + return True + + # --------------------------------------------------------------------------- # ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge # --------------------------------------------------------------------------- @@ -1546,6 +1643,17 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes task_id, repo, work_item_id, branch, guard_msg, result ) + # ORCH-027 (D5): ratchet the per-repo coverage baseline UP from this + # merged branch's measured coverage (single source of truth: + # 18-coverage-report.md). Atomic compare-and-set under the still-held + # merge-lease -> the baseline never decreases. never-raise (observability + # best-effort): a ratchet failure must never break the deploy->done path. + try: + from . import coverage_gate + coverage_gate.ratchet_baseline_on_merge(repo, work_item_id, branch, sha) + except Exception as e: # noqa: BLE001 - observability best-effort + logger.warning(f"Task {task_id}: coverage baseline ratchet failed: {e}") + merge_gate.note_merge_verified() try: self_deploy.record_merged_to_main(repo, work_item_id, branch, True) diff --git a/tests/test_config.py b/tests/test_config.py index 581f0f3..b43a179 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -248,6 +248,7 @@ def test_tc19_qg_checks_registry_unchanged(): "check_branch_mergeable", "check_staging_image_fresh", "check_security_gate", + "check_coverage_gate", } diff --git a/tests/test_coverage_gate.py b/tests/test_coverage_gate.py new file mode 100644 index 0000000..d73dfb4 --- /dev/null +++ b/tests/test_coverage_gate.py @@ -0,0 +1,471 @@ +"""ORCH-027 / TC-01..TC-15: the coverage-gate leaf module (src/coverage_gate.py). + +These exercise the DETERMINISTIC core: the pure verdict / delta / frontmatter +helpers (no binaries needed), the ratchet baseline against a real tmp SQLite DB, +the conditionality / kill-switch / fail-open behaviour with the measurer mocked, +never-raise, and the gate's integration into advance_stage / GET /queue. + +Contract under test (ADR-001 §7): + * the verdict is a deterministic pure function of (measured, baseline, floor, + policy, epsilon) — no LLM, all border / epsilon cases covered; + * the ratchet baseline only moves UP and bootstraps on the first merge; + * conditionality: empty scope -> self-hosting only; out-of-scope -> no-op N/A; + kill-switch off -> inert; + * a coverage-tool error degrades fail-open + WARNING by default, fail-closed only + when configured; + * the machine verdict lives ONLY in the YAML frontmatter (read-back == written); + * never-raise: any internal error -> a (bool, reason) pair, no exception escapes; + * self-hosting safety: the gate never deploys / restarts prod / pushes main. +""" +import os +import tempfile + +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_coverage_gate.db") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import pytest # noqa: E402 + +import src.db as db # noqa: E402 +from src import config as cfg # noqa: E402 +from src import coverage_gate as cg # noqa: E402 + +_REPO = "orchestrator" +_BRANCH = "feature/ORCH-027-code-coverage" +_WI = "ORCH-027" + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + """Isolated tmp SQLite DB + gate ON / empty scope (self-hosting) by default.""" + dbfile = tmp_path / "cov.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "coverage_gate_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "coverage_gate_repos", "", raising=False) + monkeypatch.setattr(cfg.settings, "coverage_min_percent", 80.0, raising=False) + monkeypatch.setattr(cfg.settings, "coverage_policy", "both", raising=False) + monkeypatch.setattr(cfg.settings, "coverage_epsilon", 0.5, raising=False) + monkeypatch.setattr(cfg.settings, "coverage_tool_fail_closed", False, raising=False) + monkeypatch.setattr(cfg.settings, "coverage_run_timeout_s", 900, raising=False) + db.init_db() + yield + + +# =========================================================================== +# TC-01 — policy=absolute +# =========================================================================== +def test_tc01_policy_absolute(): + # measured >= floor -> PASS + ok, _ = cg.compute_coverage_verdict(85.0, None, 80.0, "absolute", 0.0) + assert ok is True + # exactly on the floor -> PASS (>=) + ok, _ = cg.compute_coverage_verdict(80.0, None, 80.0, "absolute", 0.0) + assert ok is True + # below floor-epsilon -> FAIL + ok, _ = cg.compute_coverage_verdict(78.0, None, 80.0, "absolute", 0.5) + assert ok is False + # baseline is IGNORED under absolute (even a high baseline cannot fail it) + ok, _ = cg.compute_coverage_verdict(85.0, 99.0, 80.0, "absolute", 0.0) + assert ok is True + + +# =========================================================================== +# TC-02 — policy=baseline (no-regression / ratchet) +# =========================================================================== +def test_tc02_policy_baseline(): + # measured >= baseline -> PASS + ok, _ = cg.compute_coverage_verdict(90.0, 85.0, 0.0, "baseline", 0.0) + assert ok is True + # exactly on baseline -> PASS + ok, _ = cg.compute_coverage_verdict(85.0, 85.0, 0.0, "baseline", 0.0) + assert ok is True + # below baseline-epsilon -> FAIL + ok, _ = cg.compute_coverage_verdict(83.0, 85.0, 0.0, "baseline", 0.5) + assert ok is False + # floor is IGNORED under baseline (low measured vs floor but >= baseline -> PASS) + ok, _ = cg.compute_coverage_verdict(40.0, 30.0, 80.0, "baseline", 0.0) + assert ok is True + # bootstrap: baseline None under baseline policy -> PASS (cannot regress vs nothing) + ok, reason = cg.compute_coverage_verdict(10.0, None, 80.0, "baseline", 0.0) + assert ok is True + assert "bootstrap" in reason.lower() + + +# =========================================================================== +# TC-03 — policy=both (PASS only if BOTH hold) +# =========================================================================== +def test_tc03_policy_both(): + # both hold -> PASS + ok, _ = cg.compute_coverage_verdict(90.0, 85.0, 80.0, "both", 0.0) + assert ok is True + # absolute fails (below floor) -> FAIL even though >= baseline + ok, _ = cg.compute_coverage_verdict(82.0, 80.0, 85.0, "both", 0.0) + assert ok is False + # baseline fails (below baseline) -> FAIL even though >= floor + ok, _ = cg.compute_coverage_verdict(84.0, 90.0, 80.0, "both", 0.0) + assert ok is False + # bootstrap under both: baseline None -> only absolute decides + ok, _ = cg.compute_coverage_verdict(85.0, None, 80.0, "both", 0.0) + assert ok is True + ok, _ = cg.compute_coverage_verdict(70.0, None, 80.0, "both", 0.0) + assert ok is False + + +# =========================================================================== +# TC-04 — epsilon tolerance (anti-flap, NFR-4) +# =========================================================================== +def test_tc04_epsilon_tolerance(): + # measured 0.3% under baseline, epsilon 0.5 -> still PASS (within noise) + ok, _ = cg.compute_coverage_verdict(84.7, 85.0, 80.0, "both", 0.5) + assert ok is True + # measured 0.3% under floor, epsilon 0.5 -> still PASS + ok, _ = cg.compute_coverage_verdict(79.7, 80.0, 0.0, "absolute", 0.5) + assert ok is True + # just beyond epsilon -> FAIL + ok, _ = cg.compute_coverage_verdict(84.4, 85.0, 80.0, "baseline", 0.5) + assert ok is False + # negative epsilon is clamped to 0 (no negative tolerance) + ok, _ = cg.compute_coverage_verdict(84.9, 85.0, 0.0, "baseline", -5.0) + assert ok is False + + +# =========================================================================== +# TC-05 — ratchet baseline (up only; never lowers) +# =========================================================================== +def test_tc05_ratchet_up_only(): + # bootstrap seeds the baseline + assert db.get_coverage_baseline(_REPO) is None + assert db.ratchet_coverage_baseline(_REPO, 80.0, "sha1") is True + assert db.get_coverage_baseline(_REPO) == pytest.approx(80.0) + # higher value raises it + assert db.ratchet_coverage_baseline(_REPO, 85.0, "sha2") is True + assert db.get_coverage_baseline(_REPO) == pytest.approx(85.0) + # equal value re-stamps (idempotent, no harm) — baseline unchanged + db.ratchet_coverage_baseline(_REPO, 85.0, "sha3") + assert db.get_coverage_baseline(_REPO) == pytest.approx(85.0) + # LOWER value does NOT lower the baseline + assert db.ratchet_coverage_baseline(_REPO, 70.0, "sha4") is False + assert db.get_coverage_baseline(_REPO) == pytest.approx(85.0) + + +def test_tc05_ratchet_per_repo_isolated(): + db.ratchet_coverage_baseline(_REPO, 85.0, "s") + db.ratchet_coverage_baseline("enduro-trails", 42.0, "s") + assert db.get_coverage_baseline(_REPO) == pytest.approx(85.0) + assert db.get_coverage_baseline("enduro-trails") == pytest.approx(42.0) + + +# =========================================================================== +# TC-06 — bootstrap baseline (first init from main measurement) +# =========================================================================== +def test_tc06_bootstrap(monkeypatch, tmp_path): + # No baseline yet -> ratchet_baseline_on_merge seeds it from the artefact value. + report = ( + "---\ncoverage_status: PASS\nwork_item: ORCH-027\n" + "measured_coverage: 77.50\nbaseline: \nfloor: 0.00\npolicy: both\n" + "epsilon: 0.50\ndelta: 0.00\n---\n# body\n" + ) + monkeypatch.setattr(cg, "_report_path", lambda *a, **k: str(tmp_path / "18.md")) + (tmp_path / "18.md").write_text(report, encoding="utf-8") + assert db.get_coverage_baseline(_REPO) is None + assert cg.ratchet_baseline_on_merge(_REPO, _WI, _BRANCH, "sha") is True + assert db.get_coverage_baseline(_REPO) == pytest.approx(77.5) + + +# =========================================================================== +# TC-07 — conditionality applies(repo) (empty scope -> self-hosting only) +# =========================================================================== +def test_tc07_applies_self_hosting_only(monkeypatch): + monkeypatch.setattr(cfg.settings, "coverage_gate_repos", "", raising=False) + assert cg.coverage_gate_applies("orchestrator") is True + assert cg.coverage_gate_applies("enduro-trails") is False + + +def test_tc07_applies_csv_scope(monkeypatch): + monkeypatch.setattr(cfg.settings, "coverage_gate_repos", "foo, enduro-trails", raising=False) + assert cg.coverage_gate_applies("enduro-trails") is True + assert cg.coverage_gate_applies("orchestrator") is False + + +def test_tc07_out_of_scope_noop_no_measure(monkeypatch): + # Out-of-scope repo -> (True, "...N/A") and the expensive measurer is NOT called. + called = {"n": 0} + monkeypatch.setattr(cg, "measure_coverage", lambda *a, **k: called.__setitem__("n", called["n"] + 1) or 99.0) + ok, reason = cg.check_coverage_gate("enduro-trails", "ET-1", "feature/x") + assert ok is True + assert "N/A" in reason + assert called["n"] == 0 + + +# =========================================================================== +# TC-08 — kill-switch off -> inert (1:1 as before ORCH-027) +# =========================================================================== +def test_tc08_kill_switch_off(monkeypatch): + monkeypatch.setattr(cfg.settings, "coverage_gate_enabled", False, raising=False) + called = {"n": 0} + monkeypatch.setattr(cg, "measure_coverage", lambda *a, **k: called.__setitem__("n", called["n"] + 1) or 10.0) + ok, reason = cg.check_coverage_gate(_REPO, _WI, _BRANCH) + assert ok is True + assert "disabled" in reason + assert called["n"] == 0 + assert cg.coverage_gate_applies(_REPO) is False + + +# =========================================================================== +# TC-09 — fail-open by default on a tool error; fail-closed when configured +# =========================================================================== +def test_tc09_fail_open_default(monkeypatch, tmp_path): + monkeypatch.setattr(cg, "measure_coverage", lambda *a, **k: None) # tool error + monkeypatch.setattr(cg, "_report_path", lambda *a, **k: str(tmp_path / "18.md")) + ok, reason = cg.check_coverage_gate(_REPO, _WI, _BRANCH) + assert ok is True + assert "fail-open" in reason.lower() + # The report records the fail-open PASS. + content = (tmp_path / "18.md").read_text(encoding="utf-8") + assert "coverage_status: PASS" in content + + +def test_tc09_fail_closed_when_configured(monkeypatch, tmp_path): + monkeypatch.setattr(cfg.settings, "coverage_tool_fail_closed", True, raising=False) + monkeypatch.setattr(cg, "measure_coverage", lambda *a, **k: None) + monkeypatch.setattr(cg, "_report_path", lambda *a, **k: str(tmp_path / "18.md")) + ok, reason = cg.check_coverage_gate(_REPO, _WI, _BRANCH) + assert ok is False + assert "fail-closed" in reason.lower() + content = (tmp_path / "18.md").read_text(encoding="utf-8") + assert "coverage_status: FAIL" in content + + +# =========================================================================== +# TC-10 — never-raise (broken inputs / internal error never escape) +# =========================================================================== +def test_tc10_verdict_never_raises_on_bad_inputs(): + ok, reason = cg.compute_coverage_verdict("not-a-number", None, 80.0, "both", 0.5) + assert ok is False + assert "bad inputs" in reason + + +def test_tc10_parse_coverage_percent_tolerant(): + assert cg.parse_coverage_percent({"totals": {"percent_covered": 73.2}}) == pytest.approx(73.2) + assert cg.parse_coverage_percent({}) is None + assert cg.parse_coverage_percent("garbage") is None + assert cg.parse_coverage_percent({"totals": {}}) is None + + +def test_tc10_check_never_raises(monkeypatch): + # measure_coverage explodes -> the gate swallows it and returns a pair (fail-open). + def _boom(*a, **k): + raise RuntimeError("coverage exploded") + monkeypatch.setattr(cg, "measure_coverage", _boom) + ok, reason = cg.check_coverage_gate(_REPO, _WI, _BRANCH) + assert isinstance(ok, bool) + assert "error (fail-open)" in reason + + +def test_tc10_ratchet_never_raises_on_missing_report(monkeypatch, tmp_path): + monkeypatch.setattr(cg, "_report_path", lambda *a, **k: str(tmp_path / "nope.md")) + assert cg.ratchet_baseline_on_merge(_REPO, _WI, _BRANCH, "sha") is False + + +# =========================================================================== +# TC-11 — write/read report; single source of truth via frontmatter +# =========================================================================== +def test_tc11_report_roundtrip(tmp_path): + fields = { + "coverage_status": "PASS", + "measured_coverage": 88.25, + "baseline": 85.0, + "floor": 80.0, + "policy": "both", + "epsilon": 0.5, + "delta": 3.25, + "reason": "ok", + "measurement": "pytest --cov=src: 88.25%", + "policy_detail": "policy=both", + } + content = cg.render_coverage_report(_WI, fields) + # machine key present and parseable + ok, verdict = cg.parse_coverage_status(content) + assert ok is True + assert "PASS" in verdict + # measured_coverage read back from the SAME file (ratchet source of truth) + assert cg.read_measured_coverage(content) == pytest.approx(88.25) + + # FAIL roundtrip (FAIL token authoritative) + fields["coverage_status"] = "FAIL" + content = cg.render_coverage_report(_WI, fields) + ok, verdict = cg.parse_coverage_status(content) + assert ok is False + assert "FAIL" in verdict + + +def test_tc11_parse_missing_frontmatter(): + ok, reason = cg.parse_coverage_status("no frontmatter here") + assert ok is False + assert "coverage_status" in reason + assert cg.read_measured_coverage("no frontmatter") is None + + +def test_tc11_bootstrap_report_blank_baseline(): + # bootstrap: baseline None -> renders an EMPTY baseline field, still parseable. + fields = { + "coverage_status": "PASS", "measured_coverage": 50.0, "baseline": None, + "floor": 0.0, "policy": "both", "epsilon": 0.5, "delta": 0.0, + } + content = cg.render_coverage_report(_WI, fields) + assert "baseline: \n" in content or "baseline:\n" in content + assert cg.parse_coverage_status(content)[0] is True + + +# =========================================================================== +# TC-12 — self-hosting safety: the leaf imports no engine, touches no prod +# =========================================================================== +def test_tc12_leaf_no_engine_import(): + # AST-based (not prose): the leaf must never IMPORT the engine, and the only + # external command it runs is pytest — no docker/compose/force-push literals. + import ast + import inspect + tree = ast.parse(inspect.getsource(cg)) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module) + elif isinstance(node, ast.Import): + for n in node.names: + imported.add(n.name) + assert not any("stage_engine" in m for m in imported), imported + assert not any(("launcher" in m or "self_deploy" in m) for m in imported), imported + # No deploy / restart / force-push command tokens used as actual string literals. + consts = [ + n.value for n in ast.walk(tree) + if isinstance(n, ast.Constant) and isinstance(n.value, str) + ] + for forbidden in ("compose", "--force-with-lease", "--force", "docker"): + assert forbidden not in consts, f"coverage_gate leaf must not run {forbidden!r}" + + +def test_tc12_delta_signed(): + assert cg.compute_delta(85.0, 80.0, 70.0) == pytest.approx(5.0) # vs max(80,70) + assert cg.compute_delta(75.0, 80.0, 70.0) == pytest.approx(-5.0) + assert cg.compute_delta(50.0, None, None) == pytest.approx(0.0) + + +# =========================================================================== +# TC-13 — gate integration into advance_stage (rollback on FAIL, retry++) +# =========================================================================== +def test_tc13_advance_rolls_back_on_fail(monkeypatch): + from src import stage_engine as se + + captured = {} + + def _fake_run_qg(name, repo, wi, branch): + captured["qg"] = name + return (False, "measured=70.00% policy=both: absolute FAIL") + + monkeypatch.setattr(se, "_run_qg", _fake_run_qg) + monkeypatch.setattr(se, "update_task_stage", lambda *a, **k: None) + monkeypatch.setattr(se, "notify_stage_change", lambda *a, **k: None) + monkeypatch.setattr(se, "plane_notify_stage", lambda *a, **k: None) + monkeypatch.setattr(se, "set_issue_in_progress", lambda *a, **k: None) + monkeypatch.setattr(se, "notify_qg_failure", lambda *a, **k: None) + monkeypatch.setattr(se, "plane_add_comment", lambda *a, **k: None) + monkeypatch.setattr(se, "_developer_retry_count", lambda *a, **k: 0) + released = {"n": 0} + monkeypatch.setattr(se.merge_gate, "release_merge_lease", + lambda *a, **k: released.__setitem__("n", released["n"] + 1)) + enq = {"n": 0} + monkeypatch.setattr(se, "enqueue_job", + lambda *a, **k: enq.__setitem__("n", enq["n"] + 1) or 123) + + result = se.AdvanceResult() + intervened = se._handle_coverage_gate(1, "deploy-staging", _REPO, _WI, _BRANCH, "deployer", result) + assert intervened is True + assert captured["qg"] == "check_coverage_gate" + assert result.rolled_back_to == "development" + assert result.enqueued_agent == "developer" + assert enq["n"] == 1 + # merge lease released on the coverage rollback (ADR-001 D1/TR-2) + assert released["n"] == 1 + + +def test_tc13_advance_passes_through_on_ok(monkeypatch): + from src import stage_engine as se + monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "coverage OK")) + result = se.AdvanceResult() + intervened = se._handle_coverage_gate(1, "deploy-staging", _REPO, _WI, _BRANCH, "deployer", result) + assert intervened is False + assert result.rolled_back_to is None + + +# =========================================================================== +# TC-14 — real measurement on a minimal fixture repo (pytest --cov in worktree) +# =========================================================================== +def test_tc14_real_measurement(tmp_path, monkeypatch): + # Build a minimal project: src/ with one function, tests covering part of it. + proj = tmp_path / "fixture_repo" + (proj / "src").mkdir(parents=True) + (proj / "tests").mkdir() + (proj / "src" / "__init__.py").write_text("", encoding="utf-8") + (proj / "src" / "mod.py").write_text( + "def covered():\n return 1\n\n\ndef uncovered():\n return 2\n", + encoding="utf-8", + ) + (proj / "tests" / "test_mod.py").write_text( + "from src.mod import covered\n\n\ndef test_covered():\n assert covered() == 1\n", + encoding="utf-8", + ) + # Point the measurer's worktree resolution at our fixture. + monkeypatch.setattr(cg, "ensure_worktree", lambda repo, branch: str(proj)) + pct = cg.measure_coverage(_REPO, _BRANCH) + assert pct is not None + # mod.py: 4 statements, uncovered() body (1) unrun -> ~75%; bounds-check only. + assert 50.0 <= pct <= 90.0 + # the scratch json is cleaned up + assert not (proj / ".coverage-report.json").exists() + + +def test_tc14_measure_timeout_returns_none(monkeypatch): + import subprocess + monkeypatch.setattr(cg, "ensure_worktree", lambda r, b: "/tmp") + + def _timeout(*a, **k): + raise subprocess.TimeoutExpired(cmd="pytest", timeout=1) + monkeypatch.setattr(cg.subprocess, "run", _timeout) + assert cg.measure_coverage(_REPO, _BRANCH) is None + + +# =========================================================================== +# TC-15 — observability (snapshot block) + registry compatibility unchanged +# =========================================================================== +def test_tc15_snapshot_shape(monkeypatch): + db.ratchet_coverage_baseline(_REPO, 81.0, "sha") + snap = cg.snapshot() + assert snap["enabled"] is True + assert snap["policy"] == "both" + assert snap["floor"] == pytest.approx(80.0) + assert "baselines" in snap + assert _REPO in snap["baselines"] + assert snap["baselines"][_REPO]["coverage"] == pytest.approx(81.0) + + +def test_tc15_snapshot_never_raises(monkeypatch): + monkeypatch.setattr(db, "all_coverage_baselines", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + snap = cg.snapshot() + assert snap["enabled"] is True + assert snap["baselines"] == {} + + +def test_tc15_registry_and_transitions_unchanged(): + from src.qg.checks import QG_CHECKS + from src.stages import STAGE_TRANSITIONS + # new check registered... + assert "check_coverage_gate" in QG_CHECKS + # ...without touching the existing verdict checks (byte-for-byte names present) + for name in ( + "check_ci_green", "check_tests_passed", "check_security_gate", + "check_staging_status", "check_staging_image_fresh", "check_branch_mergeable", + ): + assert name in QG_CHECKS + # coverage is an edge sub-gate, NOT a STAGE_TRANSITIONS edge + for _stage, spec in STAGE_TRANSITIONS.items(): + assert "check_coverage_gate" not in str(spec) diff --git a/tests/test_plane_status_model.py b/tests/test_plane_status_model.py index 33edd00..a330573 100644 --- a/tests/test_plane_status_model.py +++ b/tests/test_plane_status_model.py @@ -141,6 +141,7 @@ def test_tc23_qg_checks_registry_unchanged(): "check_reviewer_verdict", "check_tests_local", "check_deploy_status", "check_staging_status", "check_branch_mergeable", "check_staging_image_fresh", "check_security_gate", # ORCH-022 integ: security-gate registered + "check_coverage_gate", # ORCH-027 integ: coverage-gate registered } diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py index 1c8c44a..5111d8e 100644 --- a/tests/test_qg_registry_snapshot.py +++ b/tests/test_qg_registry_snapshot.py @@ -31,6 +31,7 @@ _EXPECTED_QGS = { "check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge) "check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge) "check_security_gate", # ORCH-022 security sub-gate (same edge, run FIRST) + "check_coverage_gate", # ORCH-027 coverage sub-gate (same edge, after merge-gate) } diff --git a/tests/test_stage_engine.py b/tests/test_stage_engine.py index 66ced68..2926f1a 100644 --- a/tests/test_stage_engine.py +++ b/tests/test_stage_engine.py @@ -833,6 +833,7 @@ class TestMergeGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -858,6 +859,7 @@ class TestMergeGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _fail("merge-lock busy")}, ) monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30) @@ -886,6 +888,7 @@ class TestMergeGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _fail("merge-lock busy")}, ) monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3) @@ -920,6 +923,7 @@ class TestMergeGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -944,6 +948,7 @@ class TestMergeGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -968,6 +973,7 @@ class TestMergeGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -1021,6 +1027,7 @@ class TestImageFreshnessGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _fail( "staging rebuild failed: health FAILED")}, @@ -1049,6 +1056,7 @@ class TestImageFreshnessGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _fail("provenance mismatch")}, ) @@ -1073,6 +1081,7 @@ class TestImageFreshnessGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -1099,6 +1108,7 @@ class TestImageFreshnessGate: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass}, ) # check_staging_image_fresh left REAL -> N/A for enduro-trails task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099", @@ -1171,6 +1181,7 @@ class TestStagingInfraTolerance: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -1244,6 +1255,7 @@ class TestStagingInfraTolerance: {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_security_gate": _pass, + "check_coverage_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass, "check_deploy_status": _pass}, diff --git a/tests/test_stages_invariants.py b/tests/test_stages_invariants.py index 171581a..5714cb2 100644 --- a/tests/test_stages_invariants.py +++ b/tests/test_stages_invariants.py @@ -27,6 +27,7 @@ _EXPECTED_QGS = { "check_branch_mergeable", "check_staging_image_fresh", "check_security_gate", + "check_coverage_gate", } _EXPECTED_TRANSITIONS = {