Files
healthlog/scripts/gate.py
T
av 505664acf1 добавлено окружение разработки: контейнер и гейт ревью
- сервис поднимается через `task up`/`task restart`, данные вынесены в ./data
  и переживают пересборку — поток с телефона не прерывается
- `task gate` гоняет build/vet/lint/test/race/покрытие диффа/миграции, плюс два
  шага под healthlog: данные о здоровье в индексе и синхронность образцов конфига
- `task review:context` собирает вход для архитектурного прохода ревью
2026-08-01 14:11:41 +03:00

251 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Детерминированный гейт ревью.
Прогоняет всё, у чего есть объективный оракул, и печатает сводку. В отличие от
`task test`/`task lint` не останавливается на первом отказе — ревьюверу нужна
полная картина, а не первая упавшая команда.
Использование: scripts/gate.py [<base-rev>]
base-rev — база для диффа. По умолчанию merge-base с master; на самом
master — HEAD~1.
Шаги выбираются по изменённым файлам: правка документации не гоняет тесты и
линтеры. Пропущенный шаг всегда виден в сводке с причиной — молча пропущенная
проверка даёт ложное ощущение проверенности, а это ровно то, ради чего гейт и
заводился.
Коды возврата: 0 — красных шагов нет, 1 — есть.
Статусы: OK, FAIL (краснит гейт), WARN (виден, но не блокирует), SKIP.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
OUT_DIR = Path("tmp/gate")
OK, FAIL, WARN, SKIP = "OK", "FAIL", "WARN", "SKIP"
summary: list[tuple[str, str, str]] = []
def record(status: str, name: str, hint: str = "") -> None:
summary.append((status, name, hint))
def git(*args: str) -> str:
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout.strip()
def base_rev(argv: list[str]) -> str:
if len(argv) > 1:
return argv[1]
on_master = git("rev-parse", "--abbrev-ref", "HEAD") == "master"
has_master = subprocess.run(
["git", "rev-parse", "--verify", "-q", "master"], capture_output=True
).returncode == 0
if has_master and not on_master:
return git("merge-base", "HEAD", "master")
return "HEAD~1"
def changed_files(base: str) -> list[str]:
"""Изменённые файлы: закоммиченное относительно базы + рабочее дерево + новые.
Берём объединение намеренно: гейт гоняют и до коммита, и после, и лишний
прогон шага дешевле пропущенного.
"""
files = set(git("diff", "--name-only", base).splitlines())
files |= set(git("ls-files", "--others", "--exclude-standard").splitlines())
return sorted(f for f in files if f)
def run(name: str, cmd: list[str], env: dict[str, str] | None = None) -> bool:
"""Выполняет шаг, складывает вывод в tmp/gate/<name>.log."""
log = OUT_DIR / f"{name}.log"
full_env = {**os.environ, **(env or {})}
proc = subprocess.run(cmd, capture_output=True, text=True, env=full_env)
log.write_text(proc.stdout + proc.stderr, encoding="utf-8")
return proc.returncode == 0
def step(name: str, cmd: list[str], hint: str = "", env: dict[str, str] | None = None) -> bool:
ok = run(name, cmd, env)
record(OK, name) if ok else record(
FAIL, name, f"{hint + ' → ' if hint else ''}{OUT_DIR}/{name}.log"
)
return ok
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
base = base_rev(sys.argv)
changed = changed_files(base)
go_changed = any(f.endswith(".go") for f in changed)
deps_changed = any(f in ("go.mod", "go.sum") for f in changed)
migrations_changed = any(f.startswith("internal/store/migrations/") for f in changed)
config_changed = any(f.startswith("internal/config/") for f in changed)
code_changed = go_changed or deps_changed
no_code = "нет изменений в .go/go.mod — код не трогали"
print(f"== gate: база диффа {base}, изменённых файлов {len(changed)} ==")
if not code_changed:
print(" код не менялся — go-шаги пропускаются, см. сводку")
# --- Компиляция и статика ---
if code_changed:
step("build", ["go", "build", "./..."], "не собирается")
step("vet", ["go", "vet", "./..."])
if shutil.which("golangci-lint"):
step("lint", ["golangci-lint", "run"])
else:
record(SKIP, "lint", "golangci-lint не установлен (task setup)")
unformatted = [
f for f in subprocess.run(
["gofmt", "-l", "."], capture_output=True, text=True
).stdout.split()
if not f.startswith("tmp/")
]
if unformatted:
record(FAIL, "gofmt", "не отформатировано: " + " ".join(unformatted))
else:
record(OK, "gofmt")
else:
for name in ("build", "vet", "lint", "gofmt"):
record(SKIP, name, no_code)
# --- Тесты ---
if code_changed:
tests_ok = step("test", ["go", "test", "-count=1", "./..."])
# Флаки: повторный прогон. Тест, который иногда зелёный, не является
# оракулом ни для чего, поэтому расхождение — находка не ниже major.
# Стабильно красный набор флаки не проверяем: его разбирает шаг test.
if tests_ok:
if run("test-repeat", ["go", "test", "-count=1", "./..."]):
record(OK, "flaky")
else:
record(FAIL, "flaky", "прогон 1 зелёный, прогон 2 красный — флаки-тест")
else:
record(SKIP, "flaky", "набор красный — сперва чиним test")
else:
record(SKIP, "test", no_code)
record(SKIP, "flaky", no_code)
# --- Гонки ---
if not code_changed:
record(SKIP, "race", no_code)
elif shutil.which("gcc"):
step(
"race",
["go", "test", "-race", "-count=1", "./..."],
"гонка либо сборка тестов — смотри лог",
env={"CGO_ENABLED": "1"},
)
else:
record(SKIP, "race", "нет gcc: -race требует cgo. Гонки НЕ проверены")
# --- Покрытие изменённых строк ---
if not go_changed:
record(SKIP, "diff-coverage", "нет изменений в .go")
elif run("cover", ["go", "test", "-count=1", f"-coverprofile={OUT_DIR}/cover.out", "./..."]):
if run("diff-coverage", ["python3", "scripts/diff-coverage.py", f"{OUT_DIR}/cover.out", base]):
record(OK, "diff-coverage", (OUT_DIR / "diff-coverage.log").read_text().splitlines()[0])
else:
record(SKIP, "diff-coverage", f"не удалось посчитать → {OUT_DIR}/diff-coverage.log")
else:
record(SKIP, "diff-coverage", f"прогон с профилем не собрался → {OUT_DIR}/cover.log")
# --- Миграции на чистой схеме ---
if migrations_changed or go_changed:
step(
"migrations",
["go", "test", "-count=1", "-run", "Migration", "./internal/store/..."],
"миграции не накатываются с нуля",
)
else:
record(SKIP, "migrations", "миграции и код не менялись")
# --- ER-схема синхронна с миграциями ---
# Меняем структуру — обновляем ER-схему в том же change. Проверка по
# диффу, потому и живёт здесь, а не в правиле линтера.
if migrations_changed:
if "docs/database.md" in changed:
record(OK, "er-schema")
else:
record(FAIL, "er-schema", "миграция изменена, а docs/database.md — нет")
# --- Образцы конфигурации синхронны с его структурой ---
# Конвенция: config.example.toml самодокументируемый и полный. Забытое поле
# обнаруживается не тестом, а тем, что через полгода никто не знает о его
# существовании, — поэтому проверяем механически.
if config_changed:
samples = [s for s in ("config.example.toml", "config.docker.toml") if s not in changed]
if samples:
record(FAIL, "config-samples",
"internal/config изменён, а образцы — нет: " + ", ".join(samples))
else:
record(OK, "config-samples")
# --- Секреты ---
# Гоняем всегда: секрет утекает из любого файла, не только из кода. Для
# healthlog это ещё и данные о здоровье — они чувствительнее токенов.
if shutil.which("gitleaks"):
step("gitleaks", ["gitleaks", "git", "--no-banner"], "возможен секрет в истории/индексе")
else:
record(SKIP, "gitleaks", "gitleaks не установлен")
# --- Данные не утекли в репозиторий ---
# Каталог ./data (база + сырой архив) под .gitignore. Попадание любого его
# файла в индекс означает утечку выгрузок Apple Health в историю git,
# откуда их уже не убрать простым коммитом.
tracked_data = [f for f in git("ls-files").splitlines()
if f.startswith("data/") or f.endswith((".db", ".db-wal", ".db-shm"))]
if tracked_data:
record(FAIL, "no-health-data",
"данные о здоровье под контролем версий: " + ", ".join(tracked_data[:5]))
else:
record(OK, "no-health-data")
# --- Уязвимости зависимостей ---
# Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа.
if not code_changed:
record(SKIP, "govulncheck", no_code)
elif shutil.which("govulncheck"):
if run("govulncheck", ["govulncheck", "./..."]):
record(OK, "govulncheck")
else:
# Ненулевой код возврата означает и найденные уязвимости, и отказ
# самого инструмента (чаще всего код не собирается). Различаем: без
# этого «уязвимостей: 0» выглядит как проверка, которой не было.
found = (OUT_DIR / "govulncheck.log").read_text().count("\nVulnerability #")
if found:
record(WARN, "govulncheck",
f"достижимо из кода уязвимостей: {found}{OUT_DIR}/govulncheck.log")
else:
record(SKIP, "govulncheck",
f"не отработал (обычно код не собирается) → {OUT_DIR}/govulncheck.log")
else:
record(SKIP, "govulncheck", "govulncheck не установлен (task setup)")
# --- Сводка ---
print("\n== сводка ==")
for status, name, hint in summary:
print(f"{status:<5} {name:<15} {hint}")
if any(s == FAIL for s, _, _ in summary):
print("\nГЕЙТ КРАСНЫЙ — опиниативные проходы не запускаются")
return 1
print("\nгейт зелёный (шаги WARN и SKIP см. в сводке — они идут в находки"
" и в границы покрытия)")
return 0
if __name__ == "__main__":
sys.exit(main())